From 80770fdd99f0b4bedb7c3ef7a38f0fa91a6636fe Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Wed, 11 Mar 2026 16:00:47 +0000 Subject: [PATCH 01/24] Added parsed model spec Hash as constant --- lib/sapi_client/application.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/sapi_client/application.rb b/lib/sapi_client/application.rb index f9b62f1..bbc0c0b 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 + 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}") From abed4a1442261efe7e94e6dc077b00a150968011 Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Wed, 11 Mar 2026 16:01:17 +0000 Subject: [PATCH 02/24] Refactored code into final path helper method --- lib/sapi_client/application.rb | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/sapi_client/application.rb b/lib/sapi_client/application.rb index bbc0c0b..8132ee4 100644 --- a/lib/sapi_client/application.rb +++ b/lib/sapi_client/application.rb @@ -38,14 +38,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) } From 6cee24651636f23ab9628418ecb46590bf041bbc Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Wed, 11 Mar 2026 16:01:54 +0000 Subject: [PATCH 03/24] Added to_underscore method to prevent rails require --- lib/sapi_client/application.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/sapi_client/application.rb b/lib/sapi_client/application.rb index 8132ee4..7920fb4 100644 --- a/lib/sapi_client/application.rb +++ b/lib/sapi_client/application.rb @@ -103,5 +103,13 @@ def get_hierarchy_proc(endpoint, inst) inst.get_hierarchy(endpoint_url, options, scheme) 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 From 60eeadea5d956160cea4d01f11d67bf6733ab307 Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Wed, 11 Mar 2026 16:02:14 +0000 Subject: [PATCH 04/24] Added helper method for parsing model file --- lib/sapi_client/application.rb | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/lib/sapi_client/application.rb b/lib/sapi_client/application.rb index 7920fb4..df1d936 100644 --- a/lib/sapi_client/application.rb +++ b/lib/sapi_client/application.rb @@ -103,6 +103,31 @@ def get_hierarchy_proc(endpoint, inst) inst.get_hierarchy(endpoint_url, options, scheme) end 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('::', '/') From 54261b9f3e0f7749ab947c4aa60cef05f46be088 Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Wed, 11 Mar 2026 16:02:35 +0000 Subject: [PATCH 05/24] Added type2fulltype helper method for parsing model file --- lib/sapi_client/application.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/sapi_client/application.rb b/lib/sapi_client/application.rb index df1d936..5a61f3e 100644 --- a/lib/sapi_client/application.rb +++ b/lib/sapi_client/application.rb @@ -103,6 +103,16 @@ def get_hierarchy_proc(endpoint, inst) inst.get_hierarchy(endpoint_url, options, scheme) end 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) From 474ae6e65f5ed2ea929ac3547c2706af60fd3606 Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Wed, 11 Mar 2026 16:03:24 +0000 Subject: [PATCH 06/24] Added method to parse model spec file --- lib/sapi_client/application.rb | 55 ++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/lib/sapi_client/application.rb b/lib/sapi_client/application.rb index 5a61f3e..6406039 100644 --- a/lib/sapi_client/application.rb +++ b/lib/sapi_client/application.rb @@ -104,6 +104,61 @@ def get_hierarchy_proc(endpoint, inst) 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 + end + # Helper method for parsing model spec file def type2fulltype(typ, prefix2uri) spl = typ.split(':') From 5c09502879332561aa163b82499c0ed50e439783 Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Wed, 11 Mar 2026 16:03:40 +0000 Subject: [PATCH 07/24] Parse model spec as last init step --- lib/sapi_client/application.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/sapi_client/application.rb b/lib/sapi_client/application.rb index 6406039..fbcce48 100644 --- a/lib/sapi_client/application.rb +++ b/lib/sapi_client/application.rb @@ -18,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 From df9238575a0d79790262eb02d1513fb1d855303f Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Thu, 12 Mar 2026 11:54:14 +0000 Subject: [PATCH 08/24] Refactored code into helper method --- lib/sapi_client/sapi_resource.rb | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/sapi_client/sapi_resource.rb b/lib/sapi_client/sapi_resource.rb index 80ac44e..10cca26 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 @@ -240,5 +240,11 @@ 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 + end end From ddeb4219597da4d9dfebfc4ac871212e402aafdf Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Thu, 12 Mar 2026 11:54:25 +0000 Subject: [PATCH 09/24] Fixed small rubocop error --- lib/sapi_client/sapi_resource.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/sapi_client/sapi_resource.rb b/lib/sapi_client/sapi_resource.rb index 10cca26..a402b3a 100644 --- a/lib/sapi_client/sapi_resource.rb +++ b/lib/sapi_client/sapi_resource.rb @@ -193,7 +193,7 @@ 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 + if res.is_a?(SapiResource) res.resource.clone elsif res.is_a?(Hash) hash_with_symbol_keys(res) From 755399e2c3883373740bc8e684335c4bba666df8 Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Thu, 12 Mar 2026 11:54:37 +0000 Subject: [PATCH 10/24] Added method to check if property is in model spec --- lib/sapi_client/sapi_resource.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/sapi_client/sapi_resource.rb b/lib/sapi_client/sapi_resource.rb index a402b3a..f8b2f77 100644 --- a/lib/sapi_client/sapi_resource.rb +++ b/lib/sapi_client/sapi_resource.rb @@ -246,5 +246,12 @@ 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 From a48ddeaaf87372ddde73cb2d65989d6e6bef154a Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Thu, 12 Mar 2026 11:54:53 +0000 Subject: [PATCH 11/24] Respond to missing if property is in model spec as well --- lib/sapi_client/sapi_resource.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/sapi_client/sapi_resource.rb b/lib/sapi_client/sapi_resource.rb index f8b2f77..b729a07 100644 --- a/lib/sapi_client/sapi_resource.rb +++ b/lib/sapi_client/sapi_resource.rb @@ -134,7 +134,10 @@ 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) From b1ab9b05eecca4106c8948283da522503f7e0869 Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Thu, 12 Mar 2026 11:58:03 +0000 Subject: [PATCH 12/24] If property is in model spec return nil instead of error raise --- lib/sapi_client/sapi_resource.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/sapi_client/sapi_resource.rb b/lib/sapi_client/sapi_resource.rb index b729a07..692597a 100644 --- a/lib/sapi_client/sapi_resource.rb +++ b/lib/sapi_client/sapi_resource.rb @@ -142,9 +142,13 @@ def respond_to_missing?(property, _include_private = false) 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 From 317fc6dbbef3894fbfd45d5529d06abfa94942f0 Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Thu, 12 Mar 2026 16:35:25 +0000 Subject: [PATCH 13/24] Refactored method to satisfy rubocop --- lib/sapi_client/sapi_resource.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/sapi_client/sapi_resource.rb b/lib/sapi_client/sapi_resource.rb index 692597a..92545e7 100644 --- a/lib/sapi_client/sapi_resource.rb +++ b/lib/sapi_client/sapi_resource.rb @@ -200,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) + case res + when SapiResource res.resource.clone - elsif res.is_a?(Hash) + when Hash hash_with_symbol_keys(res) else { '@id': res.to_s } From fa9f49492449abf1edf9569f5d574877c71e935a Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Thu, 12 Mar 2026 16:35:35 +0000 Subject: [PATCH 14/24] Fixed more rubocop issues --- lib/sapi_client/application.rb | 2 +- lib/sapi_client/sapi_resource.rb | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/sapi_client/application.rb b/lib/sapi_client/application.rb index fbcce48..693709e 100644 --- a/lib/sapi_client/application.rb +++ b/lib/sapi_client/application.rb @@ -4,7 +4,7 @@ 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) diff --git a/lib/sapi_client/sapi_resource.rb b/lib/sapi_client/sapi_resource.rb index 92545e7..66a6a14 100644 --- a/lib/sapi_client/sapi_resource.rb +++ b/lib/sapi_client/sapi_resource.rb @@ -258,7 +258,8 @@ def type_to_string(typ) 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) + Application::PARSED_MODEL_SPEC.key?(full_type) && + Application::PARSED_MODEL_SPEC[full_type].key?(property.to_s) end end end From 851e5799908c52aaa5f49e0938182f6349ed6496 Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Fri, 13 Mar 2026 19:23:01 +0000 Subject: [PATCH 15/24] Rescue and print error message if parsing the model failed --- lib/sapi_client/application.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/sapi_client/application.rb b/lib/sapi_client/application.rb index 693709e..969d513 100644 --- a/lib/sapi_client/application.rb +++ b/lib/sapi_client/application.rb @@ -160,6 +160,8 @@ def parse_model_spec # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplex end nil + rescue StandardError => e + puts(SapiError, "Error parsing model spec file #{model_spec}: #{e.message}") end # Helper method for parsing model spec file From 474d4132e15b316eeb83a9361412f90beaed2b1e Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Fri, 13 Mar 2026 19:28:43 +0000 Subject: [PATCH 16/24] Added missing model spec for cbd api fixture --- .../fixtures/cbd_api/endpointSpecs/model.yaml | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 test/fixtures/cbd_api/endpointSpecs/model.yaml 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 From f9057c7ef00924cfaddf8a827be763cca85adc02 Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Fri, 13 Mar 2026 19:29:59 +0000 Subject: [PATCH 17/24] Added unit test for parsing the model spec file --- test/sapi_client/application_test.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 From a0b776a9244ab5f4d04d50cf2fd45d7058257b5b Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Fri, 13 Mar 2026 19:30:41 +0000 Subject: [PATCH 18/24] Added minitest-stub-const gem to gemspec --- Gemfile.lock | 2 ++ sapi-client-ruby.gemspec | 1 + 2 files changed, 3 insertions(+) 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/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' From 962e9bcbf47499d441806589fd2ff9287f062ccb Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Fri, 13 Mar 2026 19:30:51 +0000 Subject: [PATCH 19/24] Require new gem in test helper --- test/test_helper.rb | 1 + 1 file changed, 1 insertion(+) 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' From e4d8c3ff30deb2e234a362b92b4a637737a56a1f Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Fri, 13 Mar 2026 19:31:37 +0000 Subject: [PATCH 20/24] Added test to make sure nil is returned if property is not in the data but is in the model spec --- test/sapi_client/sapi_resource_test.rb | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 From d55ddff66a3ff526ca411557966dc5ba770d556c Mon Sep 17 00:00:00 2001 From: Alex Tucker Date: Wed, 25 Mar 2026 17:25:43 +0000 Subject: [PATCH 21/24] We need to use the CamelCase version of the property's value, if it exists, before falling back to returning nil. --- lib/sapi_client/sapi_resource.rb | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/sapi_client/sapi_resource.rb b/lib/sapi_client/sapi_resource.rb index 66a6a14..8ff7f0a 100644 --- a/lib/sapi_client/sapi_resource.rb +++ b/lib/sapi_client/sapi_resource.rb @@ -142,10 +142,7 @@ def respond_to_missing?(property, _include_private = false) 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 + # If 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) From dcb54cdf8e24391fd5f3f755ac8faebe24ecc50b Mon Sep 17 00:00:00 2001 From: Alex Tucker Date: Wed, 25 Mar 2026 18:04:53 +0000 Subject: [PATCH 22/24] Add newline for rubocop --- lib/sapi_client/sapi_resource.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/sapi_client/sapi_resource.rb b/lib/sapi_client/sapi_resource.rb index 8ff7f0a..0b8b1d9 100644 --- a/lib/sapi_client/sapi_resource.rb +++ b/lib/sapi_client/sapi_resource.rb @@ -142,6 +142,7 @@ def respond_to_missing?(property, _include_private = false) def method_missing(property, *_args) return self[property] if resource.key?(property) + # If 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) From b2036179abc357d5fa2e1aad01c2269c7c4f428a Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Thu, 26 Mar 2026 09:37:32 +0000 Subject: [PATCH 23/24] Added missing edge case where only camel case property is in model --- lib/sapi_client/sapi_resource.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/sapi_client/sapi_resource.rb b/lib/sapi_client/sapi_resource.rb index 0b8b1d9..884c1ab 100644 --- a/lib/sapi_client/sapi_resource.rb +++ b/lib/sapi_client/sapi_resource.rb @@ -143,10 +143,12 @@ def respond_to_missing?(property, _include_private = false) def method_missing(property, *_args) return self[property] if resource.key?(property) - # If not found, try looking for a camelCase version of the property as well + # 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) - return nil if property_in_model_spec?(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 From 5142758dff203c1ddb49b71c7f7a24b9baf52649 Mon Sep 17 00:00:00 2001 From: Bogdan Marc Date: Wed, 8 Apr 2026 12:50:49 +0100 Subject: [PATCH 24/24] Added changelog entry --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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