Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
80770fd
Added parsed model spec Hash as constant
bogdanadrianmarc Mar 11, 2026
abed4a1
Refactored code into final path helper method
bogdanadrianmarc Mar 11, 2026
6cee246
Added to_underscore method to prevent rails require
bogdanadrianmarc Mar 11, 2026
60eeade
Added helper method for parsing model file
bogdanadrianmarc Mar 11, 2026
54261b9
Added type2fulltype helper method for parsing model file
bogdanadrianmarc Mar 11, 2026
474ae6e
Added method to parse model spec file
bogdanadrianmarc Mar 11, 2026
5c09502
Parse model spec as last init step
bogdanadrianmarc Mar 11, 2026
df92385
Refactored code into helper method
bogdanadrianmarc Mar 12, 2026
ddeb421
Fixed small rubocop error
bogdanadrianmarc Mar 12, 2026
755399e
Added method to check if property is in model spec
bogdanadrianmarc Mar 12, 2026
a48ddea
Respond to missing if property is in model spec as well
bogdanadrianmarc Mar 12, 2026
b1ab9b0
If property is in model spec return nil instead of error raise
bogdanadrianmarc Mar 12, 2026
317fc6d
Refactored method to satisfy rubocop
bogdanadrianmarc Mar 12, 2026
fa9f494
Fixed more rubocop issues
bogdanadrianmarc Mar 12, 2026
851e579
Rescue and print error message if parsing the model failed
bogdanadrianmarc Mar 13, 2026
474d413
Added missing model spec for cbd api fixture
bogdanadrianmarc Mar 13, 2026
f9057c7
Added unit test for parsing the model spec file
bogdanadrianmarc Mar 13, 2026
a0b776a
Added minitest-stub-const gem to gemspec
bogdanadrianmarc Mar 13, 2026
962e9bc
Require new gem in test helper
bogdanadrianmarc Mar 13, 2026
e4d8c3f
Added test to make sure nil is returned if property is not in the dat…
bogdanadrianmarc Mar 13, 2026
d55ddff
We need to use the CamelCase version of the property's value, if it e…
ajtucker Mar 25, 2026
dcb54cd
Add newline for rubocop
ajtucker Mar 25, 2026
b203617
Added missing edge case where only camel case property is in model
bogdanadrianmarc Mar 26, 2026
5142758
Added changelog entry
bogdanadrianmarc Apr 8, 2026
a1ab533
Merge branch 'main' into issue/70-property-access
bogdanadrianmarc Jul 15, 2026
9f5a79c
Updated app version in Gemfile.lock
bogdanadrianmarc Jul 15, 2026
255f000
Fixed failing tests by regenerating Gemfile.lock
bogdanadrianmarc Jul 15, 2026
71ecb83
Deleted import of removed gem
bogdanadrianmarc Jul 15, 2026
305efb9
Make parsed_model_spec an instance variable instead of a constant
bogdanadrianmarc Jul 17, 2026
2452a5f
Use new instance variable for parsing the model spec
bogdanadrianmarc Jul 17, 2026
a19e994
Fixed rubocop error
bogdanadrianmarc Jul 17, 2026
b335c1c
Pass model spec to instance instance
bogdanadrianmarc Jul 17, 2026
5ae9cd8
Added parsed_model_spec as class attribute accessor
bogdanadrianmarc Jul 17, 2026
840d943
Use new attr_accessor in helper method
bogdanadrianmarc Jul 17, 2026
a064b17
Set parsed_model_spec value in instance initialize method
bogdanadrianmarc Jul 17, 2026
ec15a61
Fixed failing parsed_model_spec test
bogdanadrianmarc Jul 17, 2026
690c282
Updated unit test to reflect new changes
bogdanadrianmarc Jul 17, 2026
2a375e7
Updated comment to fix rubocop error
bogdanadrianmarc Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 59 additions & 16 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
PATH
remote: .
specs:
sapi-client-ruby (1.4.0)
sapi-client-ruby (1.4.1)
faraday_middleware (~> 1.0.0)
i18n (~> 1.5)

GEM
remote: https://rubygems.org/
specs:
ansi (1.5.0)
ansi (1.6.0)
ast (2.4.3)
builder (3.3.0)
byebug (11.1.3)
byebug (13.0.0)
reline (>= 0.6.0)
concurrent-ruby (1.3.7)
docile (1.4.1)
faraday (1.10.6)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
Expand All @@ -39,12 +39,13 @@ GEM
faraday-retry (1.0.4)
faraday_middleware (1.0.0)
faraday (~> 1.0)
i18n (1.14.8)
i18n (1.15.2)
concurrent-ruby (~> 1.0)
json (2.20.0)
io-console (0.8.2)
json (2.21.1)
language_server-protocol (3.17.0.6)
lint_roller (1.1.0)
minitest (5.26.1)
minitest (5.27.0)
minitest-reporters (1.8.0)
ansi
builder
Expand All @@ -53,7 +54,7 @@ GEM
mocha (2.8.2)
ruby2_keywords (>= 0.0.5)
multipart-post (2.4.1)
parallel (1.28.0)
parallel (2.1.0)
parser (3.3.11.1)
ast (~> 2.4.1)
racc
Expand All @@ -62,6 +63,8 @@ GEM
rainbow (3.1.1)
rake (13.4.2)
regexp_parser (2.12.0)
reline (0.6.3)
io-console (~> 0.5)
rubocop (1.88.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
Expand All @@ -78,20 +81,15 @@ GEM
prism (~> 1.7)
ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
simplecov (1.0.1)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
vcr (6.4.0)

PLATFORMS
ruby
x86_64-darwin-24
x86_64-linux

DEPENDENCIES
bundler
Expand All @@ -105,5 +103,50 @@ DEPENDENCIES
simplecov
vcr (~> 6.0)

CHECKSUMS
ansi (1.6.0) sha256=ac9ea0c0ea8d32fb4e271348e609963ac78882f34b73836c2a02b3622e666658
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
byebug (13.0.0) sha256=d2263efe751941ca520fa29744b71972d39cbc41839496706f5d9b22e92ae05d
concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0
faraday (1.10.6) sha256=7ff4802a6b312876a2241b3e641ce0d5045e168dd871b422c35b505e5261ad4d
faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689
faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750
faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940
faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682
faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335
faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7
faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0
faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa
faraday_middleware (1.0.0) sha256=19e808539681bbf2e65df30dfbe27bb402bde916a1dceb4c7496dbe8de14334a
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
json (2.21.1) sha256=13a43df75d95641443f5702dff350f237164a9d811ff0f2c2800d4d980220583
language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
minitest-reporters (1.8.0) sha256=8ce5280fb73ad3178ae525454df169b6f28c1b38b1d088ea91815d3a370ba384
mocha (2.8.2) sha256=1f77e729db47e72b4ef776461ce20caeec2572ffdf23365b0a03608fee8f4eee
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356
parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
rubocop (1.88.2) sha256=8def251c90cd955feb4daa3edc0ab56893250c4ce90ef81e6c80c03f9a939bbf
rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
sapi-client-ruby (1.4.1)
simplecov (1.0.1) sha256=419d93c40484159d20b40e26e9a83cdd4c3afa2f3fd443bb8946c301cd658d75
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
vcr (6.4.0) sha256=077ac92cc16efc5904eb90492a18153b5e6ca5398046d8a249a7c96a9ea24ae6

BUNDLED WITH
2.4.22
4.0.5
120 changes: 114 additions & 6 deletions lib/sapi_client/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ 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
def initialize(base_url, application_or_endpoints)
class Application # rubocop:disable Metrics/ClassLength
def initialize(base_url, application_or_endpoints) # rubocop:disable Metrics/MethodLength
unless File.exist?(application_or_endpoints)
raise(SapiError, "Could not find spec file/directory #{application_or_endpoints}")
end
Expand All @@ -16,6 +16,10 @@ def initialize(base_url, application_or_endpoints)
@specification = (@application_spec_file && YAML.load_file(application_or_endpoints)) || {
'sapi-nt' => { 'config' => { 'loadSpecPath' => 'classpath:endpointSpecs' } }
}

# Initialize parsed_model_spec and call parse_model_spec before returning
@parsed_model_spec = {}
parse_model_spec
end

attr_reader :base_url, :specification
Expand All @@ -36,14 +40,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) }
Expand All @@ -56,7 +64,7 @@ def endpoints
# and endpoint `e` will have a methdod `e()` to get the JSON items for
# that endpoint, and a method `e_spec()` to get the endpoint specification
def instance # rubocop:disable Metrics/MethodLength
inst = SapiClient::Instance.new(base_url)
inst = SapiClient::Instance.new(base_url, @parsed_model_spec)

endpoints.each do |endpoint|
inst.define_singleton_method(:"#{endpoint.name}", get_items_proc(endpoint, inst))
Expand Down Expand Up @@ -97,5 +105,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 with the results
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
3 changes: 2 additions & 1 deletion lib/sapi_client/instance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ class Instance # rubocop:disable Metrics/ClassLength
attr_accessor :request_logger
attr_reader :base_url, :logger, :instrumenter

def initialize(base_url, config = {})
def initialize(base_url, parsed_model_spec = {}, config = {})
@base_url = base_url
SapiClient::SapiResource.parsed_model_spec = parsed_model_spec
@logger = config[:logger] || rails_logger
@instrumenter = config[:instrumenter] || rails_active_support_notifications
end
Expand Down
35 changes: 31 additions & 4 deletions lib/sapi_client/sapi_resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ module SapiClient
# Commonly, this class will be the base class for creating domain-specific
# model classes to encapsulate particular API values.
class SapiResource # rubocop:disable Metrics/ClassLength
class << self
attr_accessor :parsed_model_spec
end

# Create a new Sapi Resource, wrapping an existing value. The possible values
# for `resource` are:
# - a hash of values; hash keys will be transformed to symbols
Expand Down Expand Up @@ -92,7 +96,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

Expand Down Expand Up @@ -134,15 +138,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

Expand Down Expand Up @@ -193,9 +204,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 }
Expand Down Expand Up @@ -240,5 +252,20 @@ 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)
return false unless (spec = self.class.parsed_model_spec)

types&.any? do |typ|
full_type = type_to_string(typ)
spec.key?(full_type) && spec[full_type].key?(property.to_s)
end
end
end
end
Loading