diff --git a/.rubocop.yml b/.rubocop.yml index 9b5f5f7c7..70e822775 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -8,6 +8,7 @@ AllCops: Exclude: - 'packages/forest_admin_datasource_active_record/spec/dummy/**/*' - 'packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_serializer.rb' + - 'packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_serializer_override.rb' - 'node_modules/semantic-release-rubygem/**/*' Gemspec/OrderedDependencies: @@ -37,11 +38,16 @@ Metrics/AbcSize: - 'packages/forest_admin_agent/lib/forest_admin_agent/auth/auth_manager.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/auth/oauth2/forest_provider.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/auth/oidc_client_manager.rb' + - 'packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb' + - 'packages/forest_admin_agent/lib/forest_admin_agent/utils/query_string_parser.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/generator_field.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/schema_emitter.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/serializer/json_api_serializer.rb' - 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/parser/validation.rb' - 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb' + - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb' + - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection_factory.rb' + - Metrics/CyclomaticComplexity: Exclude: @@ -159,6 +165,8 @@ Metrics/ParameterLists: Exclude: - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/schema/relations/many_to_many_schema.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/schema/column_schema.rb' + - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/caller.rb' + - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter.rb' Metrics/ModuleLength: CountAsOne: [ 'array', 'hash', 'method_call' ] @@ -197,6 +205,8 @@ RSpec/MultipleExpectations: Layout/LineLength: Max: 120 + Exclude: + - 'packages/forest_admin_agent/spec/shared/caller.rb' RSpec/MultipleMemoizedHelpers: Max: 10 diff --git a/packages/forest_admin_agent/forest_admin_agent.gemspec b/packages/forest_admin_agent/forest_admin_agent.gemspec index a9ab0f738..a8baec7ad 100644 --- a/packages/forest_admin_agent/forest_admin_agent.gemspec +++ b/packages/forest_admin_agent/forest_admin_agent.gemspec @@ -43,4 +43,6 @@ admin work on any Ruby application." spec.add_dependency "rake", "~> 13.0" spec.add_dependency "rack-cors", "~> 2.0" spec.add_dependency "zeitwerk", "~> 2.3" + spec.add_dependency "faraday", "~> 2.7" + spec.add_dependency "activesupport", "~> 7.0" end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb b/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb index 165beb9ee..b09ae3bd7 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb @@ -14,22 +14,44 @@ class AgentFactory def setup(options) @options = options @has_env_secret = options.to_h.key?(:env_secret) - # @customizer = DatasourceCustomizer.new + @customizer = ForestAdminDatasourceToolkit::Datasource.new build_container build_cache build_logger end + def add_datasource(datasource) + datasource.collections.each { |_name, collection| @customizer.add_collection(collection) } + self + end + def build - # @customizer.datasource - @container.register(:datasource, {}) + @container.register(:datasource, @customizer) send_schema end private def send_schema(force: false) - # todo + return unless @has_env_secret + + schema = ForestAdminAgent::Utils::Schema::SchemaEmitter.get_serialized_schema(@customizer) + schema_is_know = @container.key?(:schema_file_hash) && + @container.resolve(:schema_file_hash).get('value') == schema[:meta][:schemaFileHash] + + if !schema_is_know || force + # Logger::log('Info', 'schema was updated, sending new version'); + client = ForestAdminAgent::Http::ForestAdminApiRequester.new + client.post('/forest/apimaps', schema) + schema_file_hash_cache = Lightly.new(life: TTL_SCHEMA, dir: @options[:cache_dir].to_s) + schema_file_hash_cache.get 'value' do + schema[:meta][:schemaFileHash] + end + @container.register(:schema_file_hash, schema_file_hash_cache) + else + @container.resolve(:logger) + # TODO: Logger::log('Info', 'Schema was not updated since last run'); + end end def build_container diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/facades/container.rb b/packages/forest_admin_agent/lib/forest_admin_agent/facades/container.rb index bf35a7756..e637fce46 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/facades/container.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/facades/container.rb @@ -5,6 +5,10 @@ def self.instance ForestAdminAgent::Builder::AgentFactory.instance.container end + def self.datasource + instance.resolve(:datasource) + end + def self.config_from_cache instance.resolve(:cache).get('config') end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/http/forest_admin_api_requester.rb b/packages/forest_admin_agent/lib/forest_admin_agent/http/forest_admin_api_requester.rb new file mode 100644 index 000000000..98d07cf4f --- /dev/null +++ b/packages/forest_admin_agent/lib/forest_admin_agent/http/forest_admin_api_requester.rb @@ -0,0 +1,28 @@ +require 'faraday' + +module ForestAdminAgent + module Http + class ForestAdminApiRequester + def initialize + @headers = { + 'Content-Type' => 'application/json', + 'forest-secret-key' => Facades::Container.cache(:env_secret) + } + @client = Faraday.new( + Facades::Container.cache(:forest_server_url), + { + headers: @headers + } + ) + end + + def get(url, params) + @client.get(url, params.to_json) + end + + def post(url, params) + @client.post(url, params.to_json) + end + end + end +end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/http/router.rb b/packages/forest_admin_agent/lib/forest_admin_agent/http/router.rb index 1b190f190..ed3d016c1 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/http/router.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/http/router.rb @@ -9,7 +9,8 @@ def self.routes # api_charts_routes, System::HealthCheck.new.routes, Security::Authentication.new.routes, - Resources::List.new.routes + Resources::List.new.routes, + Resources::Count.new.routes ].inject(&:merge) end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_authenticated_route.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_authenticated_route.rb index 6635176ab..65bce570d 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_authenticated_route.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_authenticated_route.rb @@ -2,8 +2,10 @@ module ForestAdminAgent module Routes class AbstractAuthenticatedRoute < AbstractRoute def build(args = {}) - # TODO: handle call permissions - Facades::Whitelist.check_ip(args[:headers]['action_dispatch.remote_ip'].to_s) + if args.dig(:headers, 'action_dispatch.remote_ip') + Facades::Whitelist.check_ip(args[:headers]['action_dispatch.remote_ip'].to_s) + end + super end end end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_route.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_route.rb index 981c0f4fe..0c377dd4f 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_route.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_route.rb @@ -6,6 +6,11 @@ def initialize setup_routes end + def build(args) + @datasource = ForestAdminAgent::Facades::Container.datasource + @collection = @datasource.collection(args[:params]['collection_name']) + end + def routes @routes ||= {} end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/count.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/count.rb new file mode 100644 index 000000000..196bc3e51 --- /dev/null +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/count.rb @@ -0,0 +1,31 @@ +require 'jsonapi-serializers' + +module ForestAdminAgent + module Routes + module Resources + class Count < AbstractRoute + include ForestAdminAgent::Builder + def setup_routes + add_route('forest_count', 'get', '/:collection_name/count', ->(args) { handle_request(args) }) + + self + end + + def handle_request(args = {}) + build(args) + caller = ForestAdminAgent::Utils::QueryStringParser.parse_caller(args) + filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new + aggregation = ForestAdminDatasourceToolkit::Components::Query::Aggregation.new(operation: 'Count') + result = @collection.aggregate(caller, filter, aggregation) + + { + name: args[:params]['collection_name'], + content: { + count: result[0][:value] + } + } + end + end + end + end +end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/list.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/list.rb index cc0076d7a..9da61abdc 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/list.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/list.rb @@ -1,3 +1,5 @@ +require 'jsonapi-serializers' + module ForestAdminAgent module Routes module Resources @@ -10,10 +12,23 @@ def setup_routes end def handle_request(args = {}) - # is_collection true for a list false for a single record - # JSONAPI::Serializer.serialize(record, is_collection: true, serializer: Serializer::ForestSerializer) build(args) - { name: args['collection_name'], content: args['collection_name'] } + caller = ForestAdminAgent::Utils::QueryStringParser.parse_caller(args) + filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new( + page: ForestAdminAgent::Utils::QueryStringParser.parse_pagination(args) + ) + projection = ForestAdminAgent::Utils::QueryStringParser.parse_projection_with_pks(@collection, args) + records = @collection.list(caller, filter, projection) + + { + name: args[:params]['collection_name'], + content: JSONAPI::Serializer.serialize( + records, + is_collection: true, + serializer: Serializer::ForestSerializer, + include: projection.relations.keys + ) + } end end end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/security/authentication.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/security/authentication.rb index 8ecf050f2..d880f1992 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/security/authentication.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/security/authentication.rb @@ -26,8 +26,10 @@ def setup_routes end def handle_authentication(args = {}) - # Facades::Whitelist.check_ip(args[:headers]['action_dispatch.remote_ip'].to_s) - rendering_id = get_and_check_rendering_id args + if args.dig(:headers, 'action_dispatch.remote_ip') + Facades::Whitelist.check_ip(args[:headers]['action_dispatch.remote_ip'].to_s) + end + rendering_id = get_and_check_rendering_id args[:params] { content: { @@ -37,8 +39,10 @@ def handle_authentication(args = {}) end def handle_authentication_callback(args = {}) - # Facades::Whitelist.check_ip(args[:headers]['action_dispatch.remote_ip'].to_s) - token = auth.verify_code_and_generate_token(args) + if args.dig(:headers, 'action_dispatch.remote_ip') + Facades::Whitelist.check_ip(args[:headers]['action_dispatch.remote_ip'].to_s) + end + token = auth.verify_code_and_generate_token(args[:params]) token_data = JWT.decode( token, Facades::Container.cache(:auth_secret), diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_serializer.rb b/packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_serializer.rb index e5ace13f3..54013e7d2 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_serializer.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_serializer.rb @@ -9,6 +9,12 @@ class ForestSerializer attr_accessor :to_one_associations attr_accessor :to_many_associations + JSONAPI::Serializer.send(:include, ForestSerializerOverride) + + def initialize(object, options = nil) + super + end + def base_url Facades::Container.cache(:prefix) end @@ -27,12 +33,17 @@ def add_attribute(name, options = {}, &block) @attributes_map[name] = format_field(name, options) end + def format_field(name, options) + { + attr_or_block: block_given? ? block : name, + options: options, + } + end + def attributes - # forest_collection = ForestAdminAgent::Facades::Container.datasource.collection(object.class.name.demodulize.underscore) - # fields = forest_collection.getFields.reject { |field| field.is_a?(ForestAdminDatasourceToolkit::Schema::Relations::RelationSchema) } - # TO REMOVE - fields = [] - fields.each { |field| add_attribute(field.name) } + forest_collection = ForestAdminAgent::Facades::Container.datasource.collection(object.class.name.demodulize.underscore) + fields = forest_collection.fields.select { |_field_name, field| field.type == 'Column' } + fields.each { |field_name, _field| add_attribute(field_name) } return {} if attributes_map.nil? attributes = {} @@ -51,41 +62,42 @@ def add_to_one_association(name, options = {}, &block) @to_one_associations[name] = format_field(name, options) end - def add_to_many_association(name, options = {}, &block) - options[:include_links] = options.fetch(:include_links, true) - options[:include_data] = options.fetch(:include_data, false) - @to_many_associations ||= {} - @to_many_associations[name] = format_field(name, options) + def has_one_relationships + return {} if @to_one_associations.nil? + data = {} + @to_one_associations.each do |attribute_name, attr_data| + next if !should_include_attr?(attribute_name, attr_data) + data[attribute_name.to_sym] = attr_data + end + data end - def has_relationships(type) - return {} if send("to_#{type}_associations").nil? + def has_many_relationships + return {} if @to_many_associations.nil? data = {} - send("to_#{type}_associations").each do |attribute_name, attr_data| + @to_many_associations.each do |attribute_name, attr_data| next if !should_include_attr?(attribute_name, attr_data) - data[attribute_name] = attr_data + data[attribute_name.to_sym] = attr_data end data end - def format_field(name, options) - { - attr_or_block: block_given? ? block : name, - options: options, - } + def add_to_many_association(name, options = {}, &block) + options[:include_links] = options.fetch(:include_links, true) + options[:include_data] = options.fetch(:include_data, false) + @to_many_associations ||= {} + @to_many_associations[name] = format_field(name, options) end def relationships - # forest_collection = ForestAdminAgent::Facades::Container.datasource.collection(object.class.name.demodulize.underscore) - # relations_many_to_one = forest_collection.getFields.select { |field| field.is_a?(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) } - # relations_one_to_one = forest_collection.getFields.select { |field| field.is_a?(ForestAdminDatasourceToolkit::Schema::Relations::OneToOneSchema) } + forest_collection = ForestAdminAgent::Facades::Container.datasource.collection(object.class.name.demodulize.underscore) + relations_to_many = forest_collection.fields.select { |_field_name, field| field.type == 'HasMany' || field.type == 'ManyToMany' } + relations_to_one = forest_collection.fields.select { |_field_name, field| field.type == 'OneToOne' || field.type == 'ManyToOne' } - # TO REMOVE - relations_one_to_one = [] - relations_one_to_one.each { |name| add_to_one_association(name) } + relations_to_one.each { |field_name, _field| add_to_one_association(field_name) } data = {} - has_relationships('one').each do |attribute_name, attr_data| + has_one_relationships.each do |attribute_name, attr_data| formatted_attribute_name = format_name(attribute_name) data[formatted_attribute_name] = {} @@ -109,11 +121,9 @@ def relationships end end - # TO REMOVE - relations_many_to_one = [] - relations_many_to_one.each { |name| add_to_many_association(name) } + relations_to_many.each { |field_name, _field| add_to_many_association(field_name) } - has_relationships('many').each do |attribute_name, attr_data| + has_many_relationships.each do |attribute_name, attr_data| formatted_attribute_name = format_name(attribute_name) data[formatted_attribute_name] = {} diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_serializer_override.rb b/packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_serializer_override.rb new file mode 100644 index 000000000..f381578e2 --- /dev/null +++ b/packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_serializer_override.rb @@ -0,0 +1,103 @@ +module ForestAdminAgent + module Serializer + module ForestSerializerOverride + def self.included(base) + base.instance_eval do + def self.find_serializer_class_name(object, options) + return options[:serializer].to_s if options[:serializer] + return "#{options[:namespace]}::#{object.class.name}Serializer" if options[:namespace] + return object.jsonapi_serializer_class_name.to_s if object.respond_to?(:jsonapi_serializer_class_name) + + "#{object.class.name}Serializer" + end + + def self.find_recursive_relationships(root_object, root_inclusion_tree, results, options) + root_inclusion_tree.each do |attribute_name, child_inclusion_tree| + # Skip the sentinal value, but we need to preserve it for siblings. + next if attribute_name == :_include + + serializer = JSONAPI::Serializer.find_serializer(root_object, options) + serializer.relationships + unformatted_attr_name = serializer.unformat_name(attribute_name).to_sym + + # We know the name of this relationship, but we don't know where it is stored internally. + # Check if it is a has_one or has_many relationship. + object = nil + is_collection = false + is_valid_attr = false + if serializer.has_one_relationships.key?(unformatted_attr_name) + is_valid_attr = true + attr_data = serializer.has_one_relationships[unformatted_attr_name] + object = serializer.has_one_relationship(unformatted_attr_name, attr_data) + elsif serializer.has_many_relationships.key?(unformatted_attr_name) + is_valid_attr = true + is_collection = true + attr_data = serializer.has_many_relationships[unformatted_attr_name] + object = serializer.has_many_relationship(unformatted_attr_name, attr_data) + end + + unless is_valid_attr + raise JSONAPI::Serializer::InvalidIncludeError, "'#{attribute_name}' is not a valid include." + end + + if attribute_name != serializer.format_name(attribute_name) + expected_name = serializer.format_name(attribute_name) + + raise JSONAPI::Serializer::InvalidIncludeError, + "'#{attribute_name}' is not a valid include. Did you mean '#{expected_name}' ?" + end + + # We're finding relationships for compound documents, so skip anything that doesn't exist. + next if object.nil? + + # Full linkage: a request for comments.author MUST automatically include comments + # in the response. + objects = is_collection ? object : [object] + if child_inclusion_tree[:_include] == true + # Include the current level objects if the _include attribute exists. + # If it is not set, that indicates that this is an inner path and not a leaf and will + # be followed by the recursion below. + objects.each do |obj| + obj_serializer = JSONAPI::Serializer.find_serializer(obj, options) + # Use keys of ['posts', '1'] for the results to enforce uniqueness. + # Spec: A compound document MUST NOT include more than one resource object for each + # type and id pair. + # http://jsonapi.org/format/#document-structure-compound-documents + key = [obj_serializer.type, obj_serializer.id] + + # This is special: we know at this level if a child of this parent will also been + # included in the compound document, so we can compute exactly what linkages should + # be included by the object at this level. This satisfies this part of the spec: + # + # Spec: Resource linkage in a compound document allows a client to link together + # all of the included resource objects without having to GET any relationship URLs. + # http://jsonapi.org/format/#document-structure-resource-relationships + current_child_includes = [] + inclusion_names = child_inclusion_tree.keys.reject { |k| k == :_include } + inclusion_names.each do |inclusion_name| + current_child_includes << inclusion_name if child_inclusion_tree[inclusion_name][:_include] + end + + # Special merge: we might see this object multiple times in the course of recursion, + # so merge the include_linkages each time we see it to load all the relevant linkages. + current_child_includes += (results[key] && results[key][:include_linkages]) || [] + current_child_includes.uniq! + results[key] = { object: obj, include_linkages: current_child_includes } + end + end + + # Recurse deeper! + next if child_inclusion_tree.empty? + + # For each object we just loaded, find all deeper recursive relationships. + objects.each do |obj| + find_recursive_relationships(obj, child_inclusion_tree, results, options) + end + end + nil + end + end + end + end + end +end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/utils/query_string_parser.rb b/packages/forest_admin_agent/lib/forest_admin_agent/utils/query_string_parser.rb new file mode 100644 index 000000000..6623fda6f --- /dev/null +++ b/packages/forest_admin_agent/lib/forest_admin_agent/utils/query_string_parser.rb @@ -0,0 +1,74 @@ +require 'jwt' +require 'active_support/time' + +module ForestAdminAgent + module Utils + class QueryStringParser + include ForestAdminDatasourceToolkit::Exceptions + include ForestAdminDatasourceToolkit::Components + include ForestAdminDatasourceToolkit::Components::Query + + DEFAULT_ITEMS_PER_PAGE = '15'.freeze + DEFAULT_PAGE_TO_SKIP = '1'.freeze + + def self.parse_caller(args) + unless args.dig(:headers, 'HTTP_AUTHORIZATION') + # TODO: replace by http exception + raise ForestException, 'You must be logged in to access at this resource.' + end + + timezone = args[:params]['timezone'] + raise ForestException, 'Missing timezone' unless timezone + + raise ForestException, "Invalid timezone: #{timezone}" unless Time.find_zone(timezone) + + token = args[:headers]['HTTP_AUTHORIZATION'].split[1] + token_data = JWT.decode( + token, + Facades::Container.cache(:auth_secret), + true, + { algorithm: 'HS256' } + )[0] + token_data.delete('exp') + token_data[:timezone] = timezone + + Caller.new(**token_data.transform_keys(&:to_sym)) + end + + def self.parse_projection(collection, args) + fields = args.dig(:params, :fields, collection.name) || '' + + return ProjectionFactory.all(collection) unless fields != '' && !fields.nil? + + fields = fields.split(',').map do |field_name| + column = collection.fields[field_name.strip] + column.type == 'Column' ? field_name.strip : "#{field_name.strip}:#{args[:params][:fields][field_name.strip]}" + end + + Projection.new(fields) + end + + def self.parse_projection_with_pks(collection, args) + projection = parse_projection(collection, args) + + projection.with_pks(collection) + end + + def self.parse_pagination(args) + items_per_pages = args.dig(:params, :data, :attributes, :all_records_subset_query, :size) || + args.dig(:params, :page, :size) || DEFAULT_ITEMS_PER_PAGE + + page = args.dig(:params, :data, :attributes, :all_records_subset_query, :number) || + args.dig(:params, :page, :number) || DEFAULT_PAGE_TO_SKIP + + unless !items_per_pages.to_s.match(/\A[+]?\d+\z/).nil? || !page.to_s.match(/\A[+]?\d+\z/).nil? + raise ForestException, "Invalid pagination [limit: #{items_per_pages}, skip: #{page}]" + end + + offset = (page.to_i - 1) * items_per_pages.to_i + + Page.new(offset: offset, limit: items_per_pages.to_i) + end + end + end +end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/generator_field.rb b/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/generator_field.rb index e2d0594bc..20397a059 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/generator_field.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/generator_field.rb @@ -47,7 +47,7 @@ def build_column_schema(collection, name) isVirtual: false, reference: nil, type: convert_column_type(column.column_type), - validations: {} # TODO: FrontendValidationUtils.convertValidationList(column), + validations: [] # TODO: FrontendValidationUtils.convertValidationList(column), } end @@ -87,7 +87,7 @@ def build_many_to_many_schema(relation, collection, foreign_collection, base_sch isRequired: false, isReadOnly: origin_key.is_read_only || foreign_schema.is_read_only, isSortable: true, - validations: {}, + validations: [], reference: "#{foreign_collection.name}.#{target_name}" } ) @@ -107,7 +107,7 @@ def build_one_to_many_schema(relation, collection, foreign_collection, base_sche isRequired: false, isReadOnly: origin_key.is_read_only, isSortable: true, - validations: {}, + validations: [], reference: "#{foreign_collection.name}.#{target_name}" } ) @@ -126,7 +126,7 @@ def build_one_to_one_schema(relation, collection, foreign_collection, base_schem isRequired: false, isReadOnly: key_field.is_read_only, isSortable: target_field.is_sortable, - validations: {}, + validations: [], reference: "#{foreign_collection.name}.#{relation.origin_key_target}" } ) @@ -144,7 +144,7 @@ def build_many_to_one_schema(relation, collection, foreign_collection, base_sche isRequired: false, # TODO: check with validations isReadOnly: key_field.is_read_only, isSortable: key_field.is_sortable, - validations: {}, # TODO: FrontendValidation::convertValidationList(foreignTargetColumn) + validations: [], # TODO: FrontendValidation::convertValidationList(foreignTargetColumn) reference: "#{foreign_collection.name}.#{relation.foreign_key_target}" } ) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/schema_emitter.rb b/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/schema_emitter.rb index 15182dce2..c5a86e470 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/schema_emitter.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/schema_emitter.rb @@ -5,7 +5,7 @@ module ForestAdminAgent module Utils module Schema class SchemaEmitter - LIANA_NAME = "agent-ruby" + LIANA_NAME = "forest-rails" LIANA_VERSION = "1.0.0-beta.4" diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/auth/oauth2/forest_provider_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/auth/oauth2/forest_provider_spec.rb index ae881fa47..bb87c6ca4 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/auth/oauth2/forest_provider_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/auth/oauth2/forest_provider_spec.rb @@ -30,10 +30,8 @@ module OAuth2 context 'when getting the resource owner' do before do - allow(forest_provider).to receive_messages(secret: 'secret') - allow(forest_provider).to receive_messages(check_response: { data: 'data' }) - allow(access_token).to receive_messages(access_token: 'access_token') - allow(access_token).to receive_messages(client: forest_provider) + allow(forest_provider).to receive_messages(secret: 'secret', check_response: { data: 'data' }) + allow(access_token).to receive_messages(access_token: 'access_token', client: forest_provider) end it 'returns the resource owner' do @@ -44,8 +42,7 @@ module OAuth2 context 'when check response is called' do before do - allow(access_token).to receive_messages(access_token: 'access_token') - allow(access_token).to receive_messages(client: forest_provider) + allow(access_token).to receive_messages(access_token: 'access_token', client: forest_provider) allow(Faraday::Connection).to receive(:new).and_return(faraday_connection) end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/auth/oauth2/forest_resource_owner_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/auth/oauth2/forest_resource_owner_spec.rb index d74b3335a..81ee2e5c6 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/auth/oauth2/forest_resource_owner_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/auth/oauth2/forest_resource_owner_spec.rb @@ -31,7 +31,6 @@ module OAuth2 it 'makes a jwt' do jwt = forest_resource_owner.make_jwt decoded_jwt = JWT.decode jwt, Facades::Container.cache(:auth_secret), true, { algorithm: 'HS256' } - puts decoded_jwt h = { 'id' => 'id', 'email' => 'email', @@ -43,6 +42,7 @@ module OAuth2 'exp' => forest_resource_owner.expiration_in_seconds, 'permission_level' => 'permission_level' } + expect(decoded_jwt[0]).to eq h end end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb new file mode 100644 index 000000000..95291441c --- /dev/null +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' +require 'digest/sha1' +require 'json' + +module ForestAdminAgent + module Builder + describe AgentFactory do + context 'with agent setup' do + describe 'setup' do + it 'set @has_env_secret to true when env_secret exist' do + expect(described_class.instance.has_env_secret).to be true + end + + it 'build the container' do + expect(described_class.instance.container).not_to be_nil + end + + it 'build the cache' do + expect(described_class.instance.container.resolve(:cache)).not_to be_nil + expect(described_class.instance.container.resolve(:cache)).to be_instance_of Lightly + end + + it 'build the logger' do + expect(described_class.instance.container.resolve(:logger)).not_to be_nil + expect(described_class.instance.container.resolve(:logger)).to be_instance_of Services::LoggerService + end + end + + describe 'add_datasource' do + it 'add collections to the customizer datasource' do + datasource = ForestAdminDatasourceToolkit::Datasource.new + collection_book = ForestAdminDatasourceToolkit::Collection.new(datasource, 'Book') + datasource.add_collection(collection_book) + described_class.instance.add_datasource(datasource) + + expect(described_class.instance.customizer.collections.size).to eq(1) + expect(described_class.instance.customizer.collection('Book')).to eq(collection_book) + expect(described_class.instance.customizer.collection('Book').datasource).to eq(datasource) + end + end + + describe 'build' do + it 'add customizer to the container' do + allow(described_class.instance).to receive(:send_schema) + described_class.instance.build + + expect(described_class.instance.container.resolve(:datasource)).to eq(described_class.instance.customizer) + end + end + + describe 'send_schema' do + it 'do nothing if env_secret is nil' do + described_class.instance.instance_variable_set(:@has_env_secret, false) + allow(ForestAdminAgent::Utils::Schema::SchemaEmitter).to receive(:get_serialized_schema) + described_class.instance.build + + expect(ForestAdminAgent::Utils::Schema::SchemaEmitter).not_to have_received(:get_serialized_schema) + end + + # it 'send schema when schema hash is different' do + # allow(described_class.instance).to receive(:has_env_secret).and_return(false) + # allow(ForestAdminAgent::Utils::Schema::SchemaEmitter).to receive(:get_serialized_schema) + # allow(ForestAdminAgent::Http::ForestAdminApiRequester).to receive(:post) + # .and_return( + # { + # meta: { + # schemaFileHash: '' + # } + # } + # ) + # + # described_class.instance.build + # expect(ForestAdminAgent::Http::ForestAdminApiRequester).to receive(:post) + # end + end + end + end + end +end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/http/forest_admin_api_requester_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/http/forest_admin_api_requester_spec.rb new file mode 100644 index 000000000..b3f83020c --- /dev/null +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/http/forest_admin_api_requester_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +module ForestAdminAgent + module Http + describe ForestAdminApiRequester do + subject(:forest_admin_api_requester) { described_class.new } + + context 'when the forest admin api is called' do + let(:response) { instance_double(Faraday::Response, status: 200, body: {}) } + let(:faraday_connection) { instance_double(Faraday::Connection) } + let(:params) { { 'params' => 'params' } } + let(:url) { 'url' } + + before do + allow(Faraday::Connection).to receive(:new).and_return(faraday_connection) + allow(faraday_connection).to receive_messages(get: response, post: response) + end + + it 'returns a response on the get method' do + result = forest_admin_api_requester.get url, params + expect(result).to be_a response.class + expect(result.status).to eq 200 + expect(result.body).to eq({}) + end + + it 'returns a response on the post method' do + result = forest_admin_api_requester.post url, params + expect(result).to be_a response.class + expect(result.status).to eq 200 + expect(result.body).to eq({}) + end + end + end + end +end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/count_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/count_spec.rb new file mode 100644 index 000000000..595666dc0 --- /dev/null +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/count_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' +require 'singleton' +require 'ostruct' +require 'shared/caller' + +module ForestAdminAgent + module Routes + module Resources + include ForestAdminDatasourceToolkit + include ForestAdminDatasourceToolkit::Schema + describe Count do + include_context 'with caller' + subject(:count) { described_class.new } + let(:args) do + { + headers: { 'HTTP_AUTHORIZATION' => bearer }, + params: { + 'collection_name' => 'user', + 'timezone' => 'Europe/Paris' + } + } + end + + before do + datasource = Datasource.new + collection = Collection.new(datasource, 'user') + allow(collection).to receive(:aggregate).and_return( + [ + { value: 1, group: [] } + ] + ) + allow(ForestAdminAgent::Builder::AgentFactory.instance).to receive(:send_schema).and_return(nil) + + datasource.add_collection(collection) + ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(datasource) + ForestAdminAgent::Builder::AgentFactory.instance.build + end + + it 'return an serialized content' do + result = count.handle_request(args) + + expect(result[:name]).to eq('user') + expect(result[:content]).to eq({ count: 1 }) + end + + it 'adds the route forest_count' do + count.setup_routes + expect(count.routes.include?('forest_count')).to be true + expect(count.routes.length).to eq 1 + end + end + end + end +end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/list_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/list_spec.rb new file mode 100644 index 000000000..ce1a4ef6d --- /dev/null +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/list_spec.rb @@ -0,0 +1,82 @@ +require 'spec_helper' +require 'singleton' +require 'ostruct' +require 'shared/caller' + +module ForestAdminAgent + module Routes + module Resources + include ForestAdminDatasourceToolkit + include ForestAdminDatasourceToolkit::Schema + describe List do + include_context 'with caller' + subject(:list) { described_class.new } + let(:args) do + { + headers: { 'HTTP_AUTHORIZATION' => bearer }, + params: { + 'collection_name' => 'user', + 'timezone' => 'Europe/Paris' + } + } + end + + before do + user_class = Struct.new(:id, :first_name, :last_name) do + def name + 'user' + end + end + stub_const('User', user_class) + + datasource = Datasource.new + collection = Collection.new(datasource, 'user') + collection.add_fields( + { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'first_name' => ColumnSchema.new(column_type: 'String'), + 'last_name' => ColumnSchema.new(column_type: 'String') + } + ) + allow(collection).to receive(:list).and_return( + [ + User.new(1, 'foo', 'foo') + ] + ) + allow(ForestAdminAgent::Builder::AgentFactory.instance).to receive(:send_schema).and_return(nil) + + datasource.add_collection(collection) + ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(datasource) + ForestAdminAgent::Builder::AgentFactory.instance.build + end + + it 'return an serialized content' do + result = list.handle_request(args) + + expect(result[:name]).to eq('user') + expect(result[:content]).to eq( + 'data' => [ + { + 'type' => 'user', + 'id' => '1', + 'attributes' => { + 'id' => 1, + 'first_name' => 'foo', + 'last_name' => 'foo' + }, + 'links' => { 'self' => 'forest/user/1' } + } + ], + 'included' => [] + ) + end + + it 'adds the route forest_list' do + list.setup_routes + expect(list.routes.include?('forest_list')).to be true + expect(list.routes.length).to eq 1 + end + end + end + end +end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/security/authentication_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/security/authentication_spec.rb index be59aa5ab..9e6349f32 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/security/authentication_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/security/authentication_spec.rb @@ -47,23 +47,23 @@ module Security end it 'returns an auth url on the handle_authentication method' do - params = { 'renderingId' => rendering_id } - result = authentication.handle_authentication params + args = { params: { 'renderingId' => rendering_id } } + result = authentication.handle_authentication args expect(result[:content][:authorizationUrl]).to eq 'https://api.development.forestadmin.com/oidc/...' end it 'raises an error if renderingId is not present' do - params = {} + args = { params: {} } expect do - authentication.handle_authentication params + authentication.handle_authentication args end.to raise_error(Error, ForestAdminAgent::Utils::ErrorMessages::MISSING_RENDERING_ID) end it 'raises an error if renderingId is not an integer' do - params = { 'renderingId' => 'abc' } + args = { params: { 'renderingId' => 'abc' } } expect do - authentication.handle_authentication params + authentication.handle_authentication args end.to raise_error(Error, ForestAdminAgent::Utils::ErrorMessages::INVALID_RENDERING_ID) end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/query_string_parser_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/query_string_parser_spec.rb new file mode 100644 index 000000000..17d960cbe --- /dev/null +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/query_string_parser_spec.rb @@ -0,0 +1,256 @@ +require 'spec_helper' +require 'shared/caller' + +module ForestAdminAgent + module Utils + include ForestAdminDatasourceToolkit + include ForestAdminDatasourceToolkit::Schema + include ForestAdminDatasourceToolkit::Components::Query + + describe QueryStringParser do + include_context 'with caller' + + describe 'parse_caller' do + it 'return an instance of caller' do + args = { + headers: { + 'HTTP_AUTHORIZATION' => bearer + }, + params: { + 'timezone' => 'America/Los_Angeles' + } + } + + expect(described_class.parse_caller(args)).to be_a ForestAdminDatasourceToolkit::Components::Caller + end + + it 'raise an error if timezone is missing' do + args = { + headers: { + 'HTTP_AUTHORIZATION' => bearer + }, + params: {} + } + expect do + described_class.parse_caller(args) + end.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException, + '🌳🌳🌳 Missing timezone' + ) + end + + it 'raise an error if timezone is invalid' do + args = { + headers: { + 'HTTP_AUTHORIZATION' => bearer + }, + params: { + 'timezone' => 'foo/timezone' + } + } + expect do + described_class.parse_caller(args) + end.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException, + '🌳🌳🌳 Invalid timezone: foo/timezone' + ) + end + + it 'raise if the user is not connected' do + args = { + headers: {}, + params: { + 'timezone' => 'foo/timezone' + } + } + expect do + described_class.parse_caller(args) + end.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException, + '🌳🌳🌳 You must be logged in to access at this resource.' + ) + end + end + + describe 'parse_projection' do + let(:collection) do + datasource = Datasource.new + collection_person = Collection.new(datasource, 'Person') + collection_person.add_fields( + { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'books' => Relations::ManyToManySchema.new( + origin_key: 'person_id', + origin_key_target: 'id', + foreign_collection: 'Book', + foreign_key: 'book_id', + foreign_key_target: 'id', + through_collection: 'BookPerson' + ) + } + ) + collection = Collection.new(datasource, 'Book') + collection.add_fields( + { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'composite_id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'title' => ColumnSchema.new(column_type: 'String'), + 'author_id' => ColumnSchema.new(column_type: 'Number'), + 'author' => Relations::ManyToOneSchema.new( + foreign_key: 'author_id', + foreign_key_target: 'id', + foreign_collection: 'Person' + ) + } + ) + + datasource.add_collection(collection) + datasource.add_collection(collection_person) + + return collection + end + + context 'when request is well formed' do + it 'convert the request to a valid projection' do + args = { + params: { + fields: { 'Book' => 'id' } + } + } + + expect(described_class.parse_projection(collection, args)).to eq(Projection.new(['id'])) + end + + it 'return a projection with all the fields when the request does no contain fields' do + args = { + params: { + fields: { 'Book' => '' } + } + } + + expect(described_class.parse_projection(collection, args)).to eq( + Projection.new(%w[id composite_id title author_id author:id]) + ) + end + + it 'return the requested project without the primary keys when the request does not have the primary keys' do + args = { + params: { + fields: { 'Book' => 'title' } + } + } + + expect(described_class.parse_projection(collection, args)).to eq(Projection.new(['title'])) + end + + it 'convert the request to a valid projection on a collection with relationships' do + args = { + params: { + fields: { + 'Book' => 'id, title, author', + 'author' => 'id' + } + } + } + + expect(described_class.parse_projection(collection, args)).to eq(Projection.new(%w[id title author:id])) + end + end + end + + describe 'parse_projection_with_pks' do + let(:collection) do + datasource = Datasource.new + collection_person = Collection.new(datasource, 'Person') + collection_person.add_fields( + { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'name' => ColumnSchema.new(column_type: 'String'), + 'books' => Relations::ManyToManySchema.new( + origin_key: 'person_id', + origin_key_target: 'id', + foreign_collection: 'Book', + foreign_key: 'book_id', + foreign_key_target: 'id', + through_collection: 'BookPerson' + ) + } + ) + collection = Collection.new(datasource, 'Book') + collection.add_fields( + { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'title' => ColumnSchema.new(column_type: 'String'), + 'author_id' => ColumnSchema.new(column_type: 'Number'), + 'author' => Relations::ManyToOneSchema.new( + foreign_key: 'author_id', + foreign_key_target: 'id', + foreign_collection: 'Person' + ) + } + ) + + datasource.add_collection(collection) + datasource.add_collection(collection_person) + + return collection + end + + it 'return the requested project with the primary keys when the request does not contain the primary keys' do + args = { + params: { + fields: { 'Book' => 'title' } + } + } + + expect(described_class.parse_projection_with_pks(collection, args)).to eq(Projection.new(%w[title id])) + end + + it 'convert the request to a valid projection with pks on a collection with relationships' do + args = { + params: { + fields: { + 'Book' => 'id, author', + 'author' => 'name' + } + } + } + + expect(described_class.parse_projection_with_pks(collection, + args)).to eq(Projection.new(%w[id author:name author:id])) + end + end + + describe 'parse_pagination' do + it 'return the pagination parameter' do + args = { + params: { + page: { size: '10', number: '3' } + } + } + + expect(described_class.parse_pagination(args)).to have_attributes(offset: 20, limit: 10) + end + + it 'return the default limit 15 skip 0 when request does not provide the pagination parameters' do + expect(described_class.parse_pagination({})).to have_attributes(offset: 0, limit: 15) + end + + it 'raise an error when request provides invalid values' do + args = { + params: { + page: { size: -5, number: 'NaN' } + } + } + + expect do + described_class.parse_pagination(args) + end.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException, + '🌳🌳🌳 Invalid pagination [limit: -5, skip: NaN]' + ) + end + end + end + end +end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_field_many_to_many_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_field_many_to_many_spec.rb index cddccf621..01eaaa7b2 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_field_many_to_many_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_field_many_to_many_spec.rb @@ -81,7 +81,7 @@ module Schema isRequired: false, isSortable: true, isVirtual: false, - validations: {} + validations: [] } ) end @@ -129,7 +129,7 @@ module Schema isRequired: false, isSortable: false, isVirtual: false, - validations: {} + validations: [] } ) end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_field_one_to_many_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_field_one_to_many_spec.rb index e725dada3..e36dbd3d1 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_field_one_to_many_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_field_one_to_many_spec.rb @@ -57,7 +57,7 @@ module Schema isRequired: false, isSortable: true, isVirtual: false, - validations: {} + validations: [] } ) end @@ -81,7 +81,7 @@ module Schema isReadOnly: true, isRequired: false, isVirtual: false, - validations: {} + validations: [] } ) end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_field_one_to_one_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_field_one_to_one_spec.rb index aed8563f4..0259ad6d8 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_field_one_to_one_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_field_one_to_one_spec.rb @@ -59,7 +59,7 @@ module Schema isRequired: false, isSortable: false, isVirtual: false, - validations: {} + validations: [] } ) end @@ -84,7 +84,7 @@ module Schema isReadOnly: true, isRequired: false, isVirtual: false, - validations: {} + validations: [] } ) end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_field_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_field_spec.rb index 5ca22f164..7cd6819af 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_field_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_field_spec.rb @@ -55,7 +55,7 @@ module Schema isRequired: true, isVirtual: false, reference: nil, - validations: {} + validations: [] } ) end @@ -78,7 +78,7 @@ module Schema isRequired: false, isVirtual: false, reference: nil, - validations: {} + validations: [] } ) end diff --git a/packages/forest_admin_agent/spec/shared/caller.rb b/packages/forest_admin_agent/spec/shared/caller.rb new file mode 100644 index 000000000..e5a1e49e5 --- /dev/null +++ b/packages/forest_admin_agent/spec/shared/caller.rb @@ -0,0 +1,5 @@ +RSpec.shared_context 'with caller' do + let(:bearer) do + 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjEiLCJlbWFpbCI6Im5pY29sYXNhQGZvcmVzdGFkbWluLmNvbSIsImZpcnN0X25hbWUiOiJOaWNvbGFzIiwibGFzdF9uYW1lIjoiQWxleGFuZHJlIiwidGVhbSI6Ik9wZXJhdGlvbnMiLCJ0YWdzIjpbXSwicmVuZGVyaW5nX2lkIjoxMTQsImV4cCI6MTk5ODAzNjQ0OSwicGVybWlzc2lvbl9sZXZlbCI6ImFkbWluIn0.5LFmtMqZMfinLZLGdPvTlr22YDfU-B30z7MQxlb8vng' + end +end diff --git a/packages/forest_admin_agent/spec/spec_helper.rb b/packages/forest_admin_agent/spec/spec_helper.rb index c87246872..7f9b93d86 100644 --- a/packages/forest_admin_agent/spec/spec_helper.rb +++ b/packages/forest_admin_agent/spec/spec_helper.rb @@ -31,8 +31,8 @@ # See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration RSpec.configure do |config| config.before do - lightly = Lightly.new - lightly.clear 'config' + lightly = Lightly.new(dir: 'tmp/cache/forest_admin') + lightly.flush agent_factory = ForestAdminAgent::Builder::AgentFactory.instance agent_factory.setup( @@ -43,7 +43,8 @@ cache_dir: 'tmp/cache/forest_admin', schema_path: File.join('tmp', '.forestadmin-schema.json'), forest_server_url: 'https://api.development.forestadmin.com', - debug: true + debug: true, + prefix: 'forest' } ) end diff --git a/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb b/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb index 6ebb6c45e..a8135358e 100644 --- a/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb +++ b/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb @@ -2,17 +2,53 @@ module ForestAdminDatasourceActiveRecord class Collection < ForestAdminDatasourceToolkit::Collection include Parser::Column include Parser::Relation + include ForestAdminDatasourceToolkit::Components::Query + + attr_reader :model def initialize(datasource, model) @model = model - name = model.name.split('::').last + name = model.name.split('::').last.downcase super(datasource, name) fetch_fields fetch_associations end + def list(_caller, filter, projection) + query_joins(projection) + query_select(projection) + + @model.offset(filter.page.offset).limit(filter.page.limit).all + end + + def aggregate(_caller, _filter, aggregation) + field = aggregation.field || '*' + + [ + { + value: @model.send(aggregation.operation.downcase, field), + group: [] + } + ] + end + private + def query_joins(projection) + @model.joins(projection.relations.keys.map(&:to_sym)) + end + + def query_select(projection) + query = projection.columns.join(', ') + + projection.relations.each do |relation, fields| + relation_table = datasource.collection(relation).model.table_name + fields.each { |field| query += ", #{relation_table}.#{field}" } + end + + @model.select(query) + end + def fetch_fields @model.columns_hash.each do |column_name, column| # TODO: check is not sti column @@ -39,7 +75,7 @@ def fetch_associations add_field( association.name.to_s, ForestAdminDatasourceToolkit::Schema::Relations::OneToOneSchema.new( - foreign_collection: association.class_name, + foreign_collection: association.class_name.downcase, origin_key: association.foreign_key, origin_key_target: association.join_foreign_key ) @@ -48,7 +84,7 @@ def fetch_associations add_field( association.name.to_s, ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema.new( - foreign_collection: association.class_name, + foreign_collection: association.class_name.downcase, foreign_key: association.foreign_key, foreign_key_target: association.join_foreign_key ) @@ -58,19 +94,19 @@ def fetch_associations add_field( association.name.to_s, ForestAdminDatasourceToolkit::Schema::Relations::ManyToManySchema.new( - foreign_collection: association.class_name, + foreign_collection: association.class_name.downcase, origin_key: association.through_reflection.join_foreign_key, origin_key_target: association.through_reflection.foreign_key, foreign_key: association.join_foreign_key, foreign_key_target: association.association_primary_key, - through_collection: association.through_reflection.class_name + through_collection: association.through_reflection.class_name.downcase ) ) else add_field( association.name.to_s, ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema.new( - foreign_collection: association.class_name, + foreign_collection: association.class_name.downcase, origin_key: association.foreign_key, origin_key_target: association.join_foreign_key ) diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/caller.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/caller.rb new file mode 100644 index 000000000..242c27653 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/caller.rb @@ -0,0 +1,31 @@ +module ForestAdminDatasourceToolkit + module Components + class Caller + attr_reader :id, :email, :first_name, :last_name, :tags, :team, :rendering_id, :timezone, :permission_level, :role + + def initialize( + id:, + email:, + first_name:, + last_name:, + team:, + rendering_id:, + tags:, + timezone:, + permission_level:, + role: nil + ) + @id = id + @email = email + @first_name = first_name + @last_name = last_name + @team = team + @rendering_id = rendering_id + @tags = tags + @timezone = timezone + @permission_level = permission_level + @role = role + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/contracts/collection_contract.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/contracts/collection_contract.rb index b69c01e7c..ba79cb9ec 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/contracts/collection_contract.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/contracts/collection_contract.rb @@ -26,7 +26,7 @@ def create raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" end - def list + def list(caller, filter, projection) raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" end @@ -38,7 +38,7 @@ def delete raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" end - def aggregate + def aggregate(caller, filter, aggregation) raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'" end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/aggregation.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/aggregation.rb new file mode 100644 index 000000000..27e008591 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/aggregation.rb @@ -0,0 +1,23 @@ +module ForestAdminDatasourceToolkit + module Components + module Query + class Aggregation + include ForestAdminDatasourceToolkit::Exceptions + attr_reader :operation, :field, :groups + + def initialize(operation:, field: nil, groups: []) + validate(operation) + @operation = operation + @field = field + @groups = groups + end + + def validate(operation) + return if %w[Count Sum Avg Max Min].include? operation + + raise ForestException, "Aggregate operation #{operation} not allowed" + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter.rb new file mode 100644 index 000000000..e88c61ac1 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter.rb @@ -0,0 +1,18 @@ +module ForestAdminDatasourceToolkit + module Components + module Query + class Filter + attr_reader :condition_tree, :segment, :sort, :search, :search_extended, :page + + def initialize(condition_tree: nil, search: nil, search_extended: nil, segment: nil, sort: nil, page: nil) + @condition_tree = condition_tree + @search = search + @search_extended = search_extended + @segment = segment + @sort = sort + @page = page + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/page.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/page.rb new file mode 100644 index 000000000..24051f3de --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/page.rb @@ -0,0 +1,14 @@ +module ForestAdminDatasourceToolkit + module Components + module Query + class Page + attr_reader :offset, :limit + + def initialize(offset:, limit:) + @offset = offset + @limit = limit + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb new file mode 100644 index 000000000..83574d896 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb @@ -0,0 +1,42 @@ +module ForestAdminDatasourceToolkit + module Components + module Query + class Projection < Array + include ForestAdminDatasourceToolkit::Utils + def with_pks(collection) + ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(collection).each do |key| + push(key) unless include?(key) + end + + relations.each do |relation, projection| + schema = collection.fields[relation] + association = collection.datasource.collection(schema.foreign_collection) + projection_with_pks = projection.with_pks(association).nest(prefix: relation) + + projection_with_pks.each { |field| push(field) unless include?(field) } + end + + self + end + + def columns + reject { |field| field.include?(':') } + end + + def relations + each_with_object({}) do |path, memo| + next unless path.include?(':') + + split_path = path.split(':') + relation = split_path[0] + memo[relation] = Projection.new([split_path[1]].union(memo[relation] || [])) + end + end + + def nest(prefix: nil) + prefix ? Projection.new(map { |path| "#{prefix}:#{path}" }) : self + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection_factory.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection_factory.rb new file mode 100644 index 000000000..49314d893 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection_factory.rb @@ -0,0 +1,30 @@ +module ForestAdminDatasourceToolkit + module Components + module Query + class ProjectionFactory + include ForestAdminDatasourceToolkit::Utils + def self.all(collection) + projection_fields = collection.fields.reduce([]) do |memo, path| + column_name = path[0] + schema = path[1] + memo += [column_name] if schema.type == 'Column' + + if schema.type == 'OneToOne' || schema.type == 'ManyToOne' + relation = collection.datasource.collection(schema.foreign_collection) + relation_columns = relation.fields + .select { |_column_name, relation_column| relation_column.type == 'Column' } + .keys + .map { |relation_column_name| "#{column_name}:#{relation_column_name}" } + + memo += relation_columns + end + + memo + end + + Projection.new projection_fields + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/schema.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/schema.rb index 0ec4e044b..3c0ace682 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/schema.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/schema.rb @@ -15,6 +15,13 @@ def self.primary_key?(collection, name) field.type == 'Column' && field.is_primary_key end + + def self.primary_keys(collection) + collection.fields.keys.select do |field_name| + field = collection.fields[field_name] + field.type == 'Column' && field.is_primary_key + end + end end end end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/contracts/collection_contract_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/contracts/collection_contract_spec.rb index d615f8481..083cd6b44 100644 --- a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/contracts/collection_contract_spec.rb +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/contracts/collection_contract_spec.rb @@ -3,20 +3,41 @@ module ForestAdminDatasourceToolkit module Components module Contracts + include ForestAdminDatasourceToolkit::Components::Query describe CollectionContract do subject(:collection) { described_class.new } + let(:caller) do + Caller.new( + id: 1, + email: 'foo@foo.com', + first_name: 'foo', + last_name: 'foo', + team: 1, + rendering_id: 1, + tags: {}, + timezone: 'Europe/Paris', + role: 1, + permission_level: 'admin' + ) + end + it { expect { collection.datasource }.to raise_error(NotImplementedError) } it { expect { collection.schema }.to raise_error(NotImplementedError) } it { expect { collection.name }.to raise_error(NotImplementedError) } it { expect { collection.execute }.to raise_error(NotImplementedError) } it { expect { collection.form }.to raise_error(NotImplementedError) } it { expect { collection.create }.to raise_error(NotImplementedError) } - it { expect { collection.list }.to raise_error(NotImplementedError) } + it { expect { collection.list(caller, Filter.new, Projection.new) }.to raise_error(NotImplementedError) } it { expect { collection.update }.to raise_error(NotImplementedError) } it { expect { collection.delete }.to raise_error(NotImplementedError) } - it { expect { collection.aggregate }.to raise_error(NotImplementedError) } it { expect { collection.render_chart }.to raise_error(NotImplementedError) } + + it { + expect do + collection.aggregate(caller, Filter.new, Aggregation.new(operation: 'Count')) + end.to raise_error(NotImplementedError) + } end end end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/aggregation_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/aggregation_spec.rb new file mode 100644 index 000000000..54d0ed70a --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/aggregation_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +module ForestAdminDatasourceToolkit + module Components + module Query + include ForestAdminDatasourceToolkit::Schema + describe Aggregation do + it 'raise if the operation is invalid' do + expect do + described_class.new(operation: 'foo') + end.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException, + '🌳🌳🌳 Aggregate operation foo not allowed' + ) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/projection_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/projection_spec.rb new file mode 100644 index 000000000..5aafbbc55 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/projection_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' + +module ForestAdminDatasourceToolkit + module Components + module Query + include ForestAdminDatasourceToolkit::Schema + describe Projection do + let(:collection) do + collection = Collection.new(Datasource.new, '__collection__') + collection.add_fields( + { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'name' => ColumnSchema.new(column_type: 'String') + } + ) + + return collection + end + + describe 'with_pks' do + it 'automatically add pks to the provided projection when the pk is a single field' do + projection = described_class.new(['name']).with_pks(collection) + expect(projection).to eq(described_class.new(%w[name id])) + end + + it 'do nothing when the pks are already provided and the pk is a single field' do + projection = described_class.new(%w[name id]).with_pks(collection) + expect(projection).to eq(described_class.new(%w[name id])) + end + + it 'automatically add pks to the provided projection when the pk is a composite' do + collection = Collection.new(Datasource.new, '__collection__') + collection.add_fields( + { + 'key1' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'key2' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'name' => ColumnSchema.new(column_type: 'String') + } + ) + projection = described_class.new(['name']).with_pks(collection) + expect(projection).to eq(described_class.new(%w[name key1 key2])) + end + end + + describe 'nest' do + it 'do nothing with null' do + projection = described_class.new(%w[id name author:name other:id]) + expect(projection.nest).to eq projection + end + + it 'work with a prefix' do + projection = described_class.new(%w[id name author:name other:id]) + expect(projection.nest(prefix: 'prefix')).to eq( + described_class.new(%w[prefix:id prefix:name prefix:author:name prefix:other:id]) + ) + end + end + + describe 'columns' do + it 'return only fields of a collection' do + projection = described_class.new(%w[id name category:label]) + + expect(projection.columns).to eq(%w[id name]) + end + end + + describe 'relations' do + it 'return only the relations of a collection' do + projection = described_class.new(%w[id name category:label]) + + expect(projection.relations).to eq({ 'category' => ['label'] }) + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/schema/column_schema_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/schema/column_schema_spec.rb index 69abe8f34..44d9202bf 100644 --- a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/schema/column_schema_spec.rb +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/schema/column_schema_spec.rb @@ -9,7 +9,7 @@ module Schema it { expect(column.is_primary_key).to be false } it { expect(column.default_value).to be_nil } it { expect(column.is_read_only).to be false } - it { expect(column.is_sortable).to be true } + it { expect(column.is_sortable).to be false } it { expect(column.filter_operators).to eq [] } it { expect(column.enum_values).to eq [] } it { expect(column.validations).to eq [] } diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/utils/schema_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/utils/schema_spec.rb new file mode 100644 index 000000000..ae3ef552e --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/utils/schema_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +module ForestAdminDatasourceToolkit + module Utils + include ForestAdminDatasourceToolkit::Schema + describe Schema do + let(:collection) do + collection = Collection.new(Datasource.new, '__collection__') + collection.add_fields( + { + 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'composite_id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true), + 'author_id' => ColumnSchema.new(column_type: 'Number'), + 'author' => Relations::ManyToOneSchema.new( + foreign_key: 'author_id', + foreign_key_target: 'id', + foreign_collection: 'Person' + ) + } + ) + + return collection + end + + describe 'foreign_key?' do + it 'return true when field is a foreign_key' do + expect(described_class.foreign_key?(collection, 'author_id')).to be true + end + + it 'return false when field is not a foreign_key' do + expect(described_class.foreign_key?(collection, 'id')).to be false + end + end + + describe 'primary_key?' do + it 'return true when field is a primary_key' do + expect(described_class.primary_key?(collection, 'id')).to be true + end + + it 'return false when field is not a primary_key' do + expect(described_class.primary_key?(collection, 'author_id')).to be false + end + end + + describe 'primary_keys' do + it 'return all primary_keys of the collection' do + expect(described_class.primary_keys(collection)).to eq(%w[id composite_id]) + end + end + end + end +end diff --git a/packages/forest_admin_rails/app/controllers/forest_admin_rails/forest_controller.rb b/packages/forest_admin_rails/app/controllers/forest_admin_rails/forest_controller.rb index de1dec70c..6aa97e5c7 100644 --- a/packages/forest_admin_rails/app/controllers/forest_admin_rails/forest_controller.rb +++ b/packages/forest_admin_rails/app/controllers/forest_admin_rails/forest_controller.rb @@ -6,7 +6,7 @@ def index if ForestAdminAgent::Http::Router.routes.key? params['route_alias'] route = ForestAdminAgent::Http::Router.routes[params['route_alias']] - forest_response route[:closure].call(params) + forest_response route[:closure].call({ params: params, headers: request.headers.to_h }) else render json: { error: 'Route not found' }, status: 404 end diff --git a/packages/forest_admin_rails/lib/forest_admin_rails/engine.rb b/packages/forest_admin_rails/lib/forest_admin_rails/engine.rb index d1a23c5bc..ef971ef15 100644 --- a/packages/forest_admin_rails/lib/forest_admin_rails/engine.rb +++ b/packages/forest_admin_rails/lib/forest_admin_rails/engine.rb @@ -32,12 +32,11 @@ class Engine < ::Rails::Engine def load_configuration return unless File.exist?(Rails.root.join('config', 'forest_admin.rb')) - require Rails.root.join('config', 'forest_admin.rb') - - ForestAdminAgent::Builder::AgentFactory.instance.build - # force eager loading models Rails.application.eager_load! + + require Rails.root.join('config', 'forest_admin.rb') + forest_admin_configuration end def load_cors