diff --git a/CHANGELOG.md b/CHANGELOG.md index 012cbb5..fd13c84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,9 @@ All notable changes will be documented in this file. -## 1.4.0 - 2026-03-04 +## 1.4.0 - 2026-04 +- (Bogdan) Direct property access will return the value if that property is in the API returned JSON, nil if the property is not in the returned JSON but is in the model spec, or raise a MethodMissing exception if none of the above evaluates to true - (AlexT) When finding wrapper type, select the most specific type if more are available ## 1.3.0 - 2025-01-16 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..884c1ab 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,15 +134,22 @@ 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 not found, try looking for a camelCase version of the property cc_property = as_camel_case_method_name(property) return self[cc_property] if resource.key?(cc_property) + # If still not found, check if it's in the model spec for this resource's type(s) + return nil if property_in_model_spec?(property) || 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'