diff --git a/Gemfile.lock b/Gemfile.lock index b4c138e..2568fba 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -47,6 +47,7 @@ GEM builder minitest (>= 5.0) ruby-progressbar + minitest-stub-const (0.6) mocha (2.7.1) ruby2_keywords (>= 0.0.5) multipart-post (2.4.1) @@ -89,6 +90,7 @@ DEPENDENCIES byebug (~> 11.1.3) minitest (~> 5.25) minitest-reporters (~> 1.7) + minitest-stub-const (~> 0.6) mocha (~> 2.4) rake (~> 13.0.1) rubocop (~> 1.26.0) diff --git a/lib/sapi_client/application.rb b/lib/sapi_client/application.rb index f9b62f1..969d513 100644 --- a/lib/sapi_client/application.rb +++ b/lib/sapi_client/application.rb @@ -1,10 +1,12 @@ -# frozen-string-literal: true +# frozen_string_literal: true module SapiClient # Wraps an entire Sapi-NT application, such that we can walk over all of the # enclosed endpoint specifications to perform various operations, such as creating # methods we can call - class Application + class Application # rubocop:disable Metrics/ClassLength + PARSED_MODEL_SPEC = {} # rubocop:disable Style/MutableConstant + def initialize(base_url, application_or_endpoints) unless File.exist?(application_or_endpoints) raise(SapiError, "Could not find spec file/directory #{application_or_endpoints}") @@ -16,6 +18,9 @@ def initialize(base_url, application_or_endpoints) @specification = (@application_spec_file && YAML.load_file(application_or_endpoints)) || { 'sapi-nt' => { 'config' => { 'loadSpecPath' => 'classpath:endpointSpecs' } } } + + # Call method to parse model spec before returning + parse_model_spec end attr_reader :base_url, :specification @@ -36,14 +41,18 @@ def load_spec_path @endpoints_path || configuration['loadSpecPath'].sub(/^classpath:/, '') end - def endpoint_group_files + def final_path if @endpoints_path.nil? - Dir["#{application_spec_dir}/#{load_spec_path}/*.yaml"] + "#{application_spec_dir}/#{load_spec_path}/*.yaml" else - Dir["#{@endpoints_path}/*.yaml"] + "#{@endpoints_path}/*.yaml" end end + def endpoint_group_files + Dir[final_path] + end + def endpoints endpoint_group_files .map { |spec| SapiClient::EndpointGroup.new(base_url, spec) } @@ -97,5 +106,105 @@ def get_hierarchy_proc(endpoint, inst) inst.get_hierarchy(endpoint_url, options, scheme) end end + + # Parses the API model spec file and populates Hash with resulting class names and properties + def parse_model_spec # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity + # Load model spec file + model_spec = final_path.gsub('/*.yaml', '/model.yaml') + m = YAML.load_file(model_spec) + + # Parse class names and prefixes + qname2local = {} + m['classes'].each do |cls| + qname2local[cls['class']] = cls['name'] + end + prefix2uri = m['prefixes'] + builtins = { + 'http://www.w3.org/1999/02/22-rdf-syntax-ns#langString' => 'String', + 'http://www.w3.org/2001/XMLSchema#string' => 'String', + 'http://www.w3.org/2000/01/rdf-schema#Literal' => 'String', + 'http://www.w3.org/2001/XMLSchema#boolean' => 'bool', + 'http://www.w3.org/2001/XMLSchema#date' => 'Date', + 'http://www.w3.org/2001/XMLSchema#dateTime' => 'DateTime', + 'http://www.w3.org/2001/XMLSchema#integer' => 'Integer', + 'http://www.w3.org/2001/XMLSchema#decimal' => 'BigDecimal', + 'http://www.w3.org/2001/XMLSchema#double' => 'Float' + } + + # Parse classes and properties and populate PARSED_MODEL_SPEC + m['classes'].each do |cls| + # Skip if class has already been parsed + next if PARSED_MODEL_SPEC.keys.include?(type2fulltype(cls['class'], prefix2uri)) + + # If not, parse class and properties + PARSED_MODEL_SPEC[type2fulltype(cls['class'], prefix2uri)] = {} + cls['properties'].each do |prop| + ts = Set.new + + if prop['type'].is_a?(Array) + prop['type'].each do |t| + ts << type2ruby(t, prefix2uri, qname2local, builtins) + end + else + ts << type2ruby(prop['type'], prefix2uri, qname2local, builtins) + end + ts << 'nil' if prop['optional'] + + PARSED_MODEL_SPEC[type2fulltype(cls['class'], prefix2uri)][prop['name']] = returns(ts) + snake_prop = to_underscore(prop['name']) + if snake_prop != prop['name'] + PARSED_MODEL_SPEC[type2fulltype(cls['class'], prefix2uri)][snake_prop] = + returns(ts) + end + end + end + + nil + rescue StandardError => e + puts(SapiError, "Error parsing model spec file #{model_spec}: #{e.message}") + end + + # Helper method for parsing model spec file + def type2fulltype(typ, prefix2uri) + spl = typ.split(':') + pref = prefix2uri[spl[0]] + pref + spl[1] + rescue StandardError + typ + end + + # Helper method for parsing model spec file + def type2ruby(typ, prefix2uri, qname2local, builtins) + full_uri = type2fulltype(typ, prefix2uri) + if qname2local.include? typ + qname2local[typ] + elsif builtins.include? full_uri + builtins[full_uri] + else + 'String' + end + rescue StandardError + 'String' + end + + # Helper method for parsing model spec file + def returns(types) + if types.size > 1 + "( #{types.join(' | ')} )" + elsif types.size == 1 + types.first + else + 'untyped' + end + end + + # Helper method for parsing model spec file + def to_underscore(string) + string.gsub('::', '/') + .gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') + .tr('-', '_') + .downcase + end end end diff --git a/lib/sapi_client/sapi_resource.rb b/lib/sapi_client/sapi_resource.rb index 80ac44e..66a6a14 100644 --- a/lib/sapi_client/sapi_resource.rb +++ b/lib/sapi_client/sapi_resource.rb @@ -1,4 +1,4 @@ -# frozen-string-literal: true +# frozen_string_literal: true module SapiClient # Encapsulates a JSON-LD -style resource that we get back from a Sapi-NT endpoint, @@ -92,7 +92,7 @@ def types # @return True if this resource has the given URI among its types def type?(uri) - type_uris = types&.map { |typ| typ.is_a?(String) ? typ : typ['@id'] } + type_uris = types&.map { |typ| type_to_string(typ) } type_uris&.include?(uri) end @@ -134,14 +134,21 @@ def name(options = {}) end def respond_to_missing?(property, _include_private = false) - resource.key?(property) || resource.key?(as_camel_case_method_name(property)) + resource.key?(property) || + resource.key?(as_camel_case_method_name(property)) || + property_in_model_spec?(property) || + property_in_model_spec?(as_camel_case_method_name(property)) end def method_missing(property, *_args) return self[property] if resource.key?(property) + # If the property is not found, check if it's in the model spec for this resource's type(s) + return nil if property_in_model_spec?(property) + # If still not found, try looking for a camelCase version of the property as well cc_property = as_camel_case_method_name(property) return self[cc_property] if resource.key?(cc_property) + return nil if property_in_model_spec?(cc_property) super end @@ -193,9 +200,10 @@ def lang_select(values, preferred_lang) # Return the given value as an un-wrapped resource. A Hash given to this # method will have its keys transformed to symbols. def as_resource(res) - if res.is_a?(SapiResource) # rubocop:disable Style/CaseLikeIf + case res + when SapiResource res.resource.clone - elsif res.is_a?(Hash) + when Hash hash_with_symbol_keys(res) else { '@id': res.to_s } @@ -240,5 +248,19 @@ def as_camel_case_method_name(str) first_segment, *remaining_segments = str.to_s.split('_') [first_segment, *remaining_segments.map(&:capitalize)].join.to_sym end + + # Helper method to convert type to string + def type_to_string(typ) + typ.is_a?(String) ? typ : typ['@id'] + end + + # Helper method to find if property is in PARSED_MODEL_SPEC for the corresponding type + def property_in_model_spec?(property) + types&.any? do |typ| + full_type = type_to_string(typ) + Application::PARSED_MODEL_SPEC.key?(full_type) && + Application::PARSED_MODEL_SPEC[full_type].key?(property.to_s) + end + end end end diff --git a/sapi-client-ruby.gemspec b/sapi-client-ruby.gemspec index 449e96b..1118d61 100644 --- a/sapi-client-ruby.gemspec +++ b/sapi-client-ruby.gemspec @@ -38,6 +38,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'byebug', '~> 11.1.3' spec.add_development_dependency 'minitest', '~> 5.25' spec.add_development_dependency 'minitest-reporters', '~> 1.7' + spec.add_development_dependency 'minitest-stub-const', '~> 0.6' spec.add_development_dependency 'mocha', '~> 2.4' spec.add_development_dependency 'rake', '~> 13.0.1' spec.add_development_dependency 'rubocop', '~> 1.26.0' diff --git a/test/fixtures/cbd_api/endpointSpecs/model.yaml b/test/fixtures/cbd_api/endpointSpecs/model.yaml new file mode 100644 index 0000000..1141e86 --- /dev/null +++ b/test/fixtures/cbd_api/endpointSpecs/model.yaml @@ -0,0 +1,216 @@ +type: model +name: model +prefixes: + core: http://data.food.gov.uk/cbd-products/def/core/ + dct: http://purl.org/dc/terms/ + owl: http://www.w3.org/2002/07/owl# + rdf: http://www.w3.org/1999/02/22-rdf-syntax-ns# + rdfs: http://www.w3.org/2000/01/rdf-schema# + skos: http://www.w3.org/2004/02/skos/core# + xsd: http://www.w3.org/2001/XMLSchema# + dcat: http://www.w3.org/ns/dcat# + foaf: http://xmlns.com/foaf/0.1/ + vcard: http://www.w3.org/2006/vcard/ns# + def-cbd: http://data.food.gov.uk/cbd-products/def/ + pl-base: http://data.food.gov.uk/cbd-products/id/ + cbd-base: http://data.food.gov.uk/cbd-products/id/ + codespace: http://data.food.gov.uk/cbd-products/id/codespace/ + cdt: http://w3id.org/lindt/custom_datatypes# + cbd-listing: http://data.food.gov.uk/cbd-products/id/listing/ + cbd-supplier: http://data.food.gov.uk/cbd-products/id/supplier/ + cbd-status: http://data.food.gov.uk/cbd-products/id/application-status/ + +properties: + - prop: "rdf:type" + name: "type" + kind: "object" + type: "rdfs:Resource" + optional: true + multi: true + unique: false + - prop: "core:searchText" + name: "searchText" + type: "xsd:string" + optional: true + - prop: "skos:notation" + name: "notation" + type: "xsd:string" + optional: true + - prop: "core:memberOf" + name: "memberOf" + type: "code:Collection" + multi: false + optional: true + - prop: "core:dateTimeStamp" + name: "dateTimeStamp" + type: "xsd:date" + optional: true + - prop: "core:lastModified" + name: "lastModified" + type: "xsd:date" + optional: true + - prop: "core:remark" + name: "remark" + type: "xsd:string" + optional: true + - prop: "core:status" + name: "status" + type: "core:Concept" + optional: true + +classes: + - class: "core:Thing" + name: "Thing" + properties: + - "rdf:type" + - "skos:notation" + - "core:searchText" + - "core:dateTimeStamp" + - "core:memberOf" + + - class: "core:Agent" + name: "Agent" + properties: + - "rdf:type" + - "skos:notation" + - "core:searchText" + - "core:dateTimeStamp" + - "core:memberOf" + - prop: "foaf:name" + name: "name" + kind: "datatype" + type: "rdf:langString" + optional: true + multi: true + unique: false + comment: "A name for some thing." + + - class: "core:Concept" + name: "Concept" + properties: + - "rdf:type" + - "skos:notation" + - "core:searchText" + - "core:dateTimeStamp" + - "core:memberOf" + - prop: "skos:inScheme" + name: "inScheme" + type: "core:Concept" + - prop: "skos:prefLabel" + name: "prefLabel" + type: "xsd:string" + multi: false + + - class: "core:ConceptScheme" + name: "ConceptScheme" + properties: + - "rdf:type" + - "skos:notation" + - "core:searchText" + - "core:dateTimeStamp" + - "core:memberOf" + - prop: "skos:hasTopConcept" + name: "hasTopConcept" + type: "core:Concept" + + - class: "core:List" + name: "List" + properties: + - "rdf:type" + - "skos:notation" + - "core:searchText" + - "core:dateTimeStamp" + - "core:memberOf" + - prop: "core:statusScheme" + name: "statusScheme" + type: "core:ConceptScheme" + - prop: "core:supplierGroup" + name: "supplierGroup" + type: "core:Collection" + - prop: "core:listEntryClass" + name: "listEntryClass" + type: "owl:Class" + + - class: "core:Listing" + name: "Listing" + properties: + - "rdf:type" + - "skos:notation" + - "core:searchText" + - "core:dateTimeStamp" + - prop: "core:memberOf" + name: "memberOf" + type: "core:List" + - "core:status" + - "core:remark" + + - class: "core:ProductListing" + name: "ProductListing" + properties: + - "rdf:type" + - "skos:notation" + - "core:searchText" + - "core:dateTimeStamp" + - "core:lastModified" + - prop: "core:memberOf" + name: "memberOf" + type: "core:List" + - "core:remark" + - prop: "core:applicationNumber" + name: "applicationNumber" + optional: true + multi: true + type: "xsd:string" + - prop: "core:status" + name: "status" + type: "core:Concept" + optional: true + valueBase: "cbd-status:" + - prop: "core:manufacturerSupplier" + name: "manufacturerSupplier" + type: "core:Agent" + optional: true + valueBase: "cbd-supplier:" + - prop: "core:productId" + name: "productId" + optional: true + type: "xsd:string" + - prop: "core:productLinkedToApplication" + name: "productLinkedToApplication" + optional: true + type: "xsd:string" + - prop: "core:productName" + name: "productName" + type: "xsd:string" + - prop: "core:productSizeVolumeQuantity" + name: "productSizeVolumeQuantity" + optional: true + type: "xsd:string" + - class: "core:List" + name: "List" + properties: + - "rdf:type" + - "skos:notation" + - "core:searchText" + - "core:lastModified" + - prop: "skos:prefLabel" + name: "prefLabel" + type: "xsd:string" + multi: false + - prop: "core:memberOf" + name: "memberOf" + type: "core:List" + - "core:status" + - "core:remark" + - prop: "core:listEntryClass" + name: "listEntryClass" + type: "owl:Class" + optional: true + - prop: "core:statusScheme" + name: "statusScheme" + type: "skos:ConceptScheme" + optional: true + - prop: "core:supplierGroup" + name: "supplierGroup" + type: "core:Collection" + optional: true diff --git a/test/sapi_client/application_test.rb b/test/sapi_client/application_test.rb index 31e9f75..710dda4 100644 --- a/test/sapi_client/application_test.rb +++ b/test/sapi_client/application_test.rb @@ -97,6 +97,18 @@ def initialize(_json) _(hierarchy.roots.size).must_equal(5) end end + + describe '#parsed_model_spec' do + it 'should populate the parsed model spec on initialization' do + VCR.use_cassette('application.test_parsed_model_spec') do + app = SapiClient::Application.new( + 'http://fsa-rp-test.epimorphics.net', + 'test/fixtures/regulated-products/application.yaml' + ) + _(app.class.const_get(:PARSED_MODEL_SPEC).size).must_be :>, 0 + end + end + end end end end diff --git a/test/sapi_client/sapi_resource_test.rb b/test/sapi_client/sapi_resource_test.rb index 9df921d..fbd76d9 100644 --- a/test/sapi_client/sapi_resource_test.rb +++ b/test/sapi_client/sapi_resource_test.rb @@ -1,4 +1,4 @@ -# frozen-string-literal: true +# frozen_string_literal: true require 'test_helper' require 'sapi_client' @@ -344,6 +344,13 @@ class SapiResourceTest < Minitest::Test fixture = SapiClient::SapiResource.new(prefLabel: 'I am Womble!') _(fixture.pref_label).must_equal('I am Womble!') end + + it 'should return nil for a property that is not present on the resource but is in the model spec' do + SapiClient::Application.stub_const(:PARSED_MODEL_SPEC, { 'http://wimbledon.org/Womble' => { 'home' => 'String' } }) do + fixture = SapiClient::SapiResource.new(name: 'Tobermory', type: { '@id' => 'http://wimbledon.org/Womble' }) + _(fixture.home).must_be_nil + end + end end describe 'assignment' do diff --git a/test/test_helper.rb b/test/test_helper.rb index 2b563e2..e72cc75 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -19,6 +19,7 @@ require 'minitest/autorun' require 'minitest/mock' require 'minitest/reporters' +require 'minitest/stub_const' require 'mocha/minitest' require 'vcr'