From dc53d15e574be240897d92fb46f61ef4b0a0b168 Mon Sep 17 00:00:00 2001 From: Alex Tucker Date: Thu, 6 Aug 2026 14:52:39 +0100 Subject: [PATCH 01/13] Add steep Gem and config for type checking. --- Gemfile | 1 + Steepfile | 6 ++++++ 2 files changed, 7 insertions(+) create mode 100644 Steepfile diff --git a/Gemfile b/Gemfile index 4541a26..bd161c6 100644 --- a/Gemfile +++ b/Gemfile @@ -19,6 +19,7 @@ group :development, :test do gem 'rake' gem 'rubocop' gem 'simplecov', require: false + gem 'steep', require: false gem 'vcr' gem 'webmock' end diff --git a/Steepfile b/Steepfile new file mode 100644 index 0000000..a046de1 --- /dev/null +++ b/Steepfile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +target :lib do + check 'lib' + signature 'sig' +end \ No newline at end of file From 702ea407a1441feccb6237fb8d89d108ba70cdee Mon Sep 17 00:00:00 2001 From: Alex Tucker Date: Thu, 6 Aug 2026 14:54:56 +0100 Subject: [PATCH 02/13] Grab type sigs for bundled gems. ```shell rbs collection init rbs collection install ``` --- .gitignore | 3 + rbs_collection.lock.yaml | 200 +++++++++++++++++++++++++++++++++++++++ rbs_collection.yaml | 19 ++++ 3 files changed, 222 insertions(+) create mode 100644 rbs_collection.lock.yaml create mode 100644 rbs_collection.yaml diff --git a/.gitignore b/.gitignore index bd00448..781633b 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,6 @@ yarn-debug.log* /lib/bundler/man/ # End of https://www.toptal.com/developers/gitignore/api/rails,ruby + +.gem_rbs_collection +.idea \ No newline at end of file diff --git a/rbs_collection.lock.yaml b/rbs_collection.lock.yaml new file mode 100644 index 0000000..5b810b3 --- /dev/null +++ b/rbs_collection.lock.yaml @@ -0,0 +1,200 @@ +--- +path: ".gem_rbs_collection" +gems: +- name: addressable + version: '2.8' + source: + type: git + name: ruby/gem_rbs_collection + revision: 82762423fc5694b82888ea89a972847cf19838c2 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: ast + version: '2.4' + source: + type: git + name: ruby/gem_rbs_collection + revision: 82762423fc5694b82888ea89a972847cf19838c2 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: base64 + version: 0.3.0 + source: + type: rubygems +- name: bigdecimal + version: '3.1' + source: + type: git + name: ruby/gem_rbs_collection + revision: 82762423fc5694b82888ea89a972847cf19838c2 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: cgi + version: '0.5' + source: + type: git + name: ruby/gem_rbs_collection + revision: 82762423fc5694b82888ea89a972847cf19838c2 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: delegate + version: '0' + source: + type: stdlib +- name: faraday + version: '2.7' + source: + type: git + name: ruby/gem_rbs_collection + revision: 82762423fc5694b82888ea89a972847cf19838c2 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: fileutils + version: '0' + source: + type: stdlib +- name: forwardable + version: '0' + source: + type: stdlib +- name: hashdiff + version: '1.1' + source: + type: git + name: ruby/gem_rbs_collection + revision: 82762423fc5694b82888ea89a972847cf19838c2 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: json + version: '0' + source: + type: stdlib +- name: lint_roller + version: '1.1' + source: + type: git + name: ruby/gem_rbs_collection + revision: 82762423fc5694b82888ea89a972847cf19838c2 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: logger + version: '1.7' + source: + type: git + name: ruby/gem_rbs_collection + revision: 82762423fc5694b82888ea89a972847cf19838c2 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: minitest + version: '5.25' + source: + type: git + name: ruby/gem_rbs_collection + revision: 82762423fc5694b82888ea89a972847cf19838c2 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: monitor + version: '0' + source: + type: stdlib +- name: mutex_m + version: 0.3.0 + source: + type: rubygems +- name: net-http + version: '0' + source: + type: stdlib +- name: net-protocol + version: '0' + source: + type: stdlib +- name: parallel + version: '1.20' + source: + type: git + name: ruby/gem_rbs_collection + revision: 82762423fc5694b82888ea89a972847cf19838c2 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: parser + version: '3.2' + source: + type: git + name: ruby/gem_rbs_collection + revision: 82762423fc5694b82888ea89a972847cf19838c2 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: prism + version: 1.9.0 + source: + type: rubygems +- name: rainbow + version: '3.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: 82762423fc5694b82888ea89a972847cf19838c2 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: rake + version: '13.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: 82762423fc5694b82888ea89a972847cf19838c2 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: regexp_parser + version: '2.8' + source: + type: git + name: ruby/gem_rbs_collection + revision: 82762423fc5694b82888ea89a972847cf19838c2 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: rubocop + version: '1.57' + source: + type: git + name: ruby/gem_rbs_collection + revision: 82762423fc5694b82888ea89a972847cf19838c2 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: rubocop-ast + version: '1.46' + source: + type: git + name: ruby/gem_rbs_collection + revision: 82762423fc5694b82888ea89a972847cf19838c2 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: stringio + version: '0' + source: + type: stdlib +- name: tempfile + version: '0' + source: + type: stdlib +- name: timeout + version: '0' + source: + type: stdlib +- name: tmpdir + version: '0' + source: + type: stdlib +- name: uri + version: '0' + source: + type: stdlib +- name: webmock + version: '3.19' + source: + type: git + name: ruby/gem_rbs_collection + revision: 82762423fc5694b82888ea89a972847cf19838c2 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +gemfile_lock_path: Gemfile.lock diff --git a/rbs_collection.yaml b/rbs_collection.yaml new file mode 100644 index 0000000..66e30ec --- /dev/null +++ b/rbs_collection.yaml @@ -0,0 +1,19 @@ +# Download sources +sources: + - type: git + name: ruby/gem_rbs_collection + remote: https://github.com/ruby/gem_rbs_collection.git + revision: main + repo_dir: gems + +# You can specify local directories as sources also. +# - type: local +# path: path/to/your/local/repository + +# A directory to install the downloaded RBSs +path: .gem_rbs_collection + +# gems: +# # If you want to avoid installing rbs files for gems, you can specify them here. +# - name: GEM_NAME +# ignore: true From 781bfb2456a3826e3fa2f6843a55b218d6a35a17 Mon Sep 17 00:00:00 2001 From: Alex Tucker Date: Thu, 6 Aug 2026 15:07:02 +0100 Subject: [PATCH 03/13] Generate prototype typesigs: ```shell for rb in lib/data_services_api/*.rb; do rbs prototype rb $rb > sig/data_services_api/$(basename $rb)s; done ``` --- sig/data_services_api/aspect.rbs | 29 +++++ sig/data_services_api/dataset.rbs | 38 ++++++ .../dsapi_response_converter.rbs | 28 +++++ sig/data_services_api/query_generator.rbs | 60 ++++++++++ sig/data_services_api/sapint_converter.rbs | 42 +++++++ sig/data_services_api/service.rbs | 111 ++++++++++++++++++ sig/data_services_api/service_exception.rbs | 19 +++ sig/data_services_api/value.rbs | 22 ++++ sig/data_services_api/version.rbs | 12 ++ 9 files changed, 361 insertions(+) create mode 100644 sig/data_services_api/aspect.rbs create mode 100644 sig/data_services_api/dataset.rbs create mode 100644 sig/data_services_api/dsapi_response_converter.rbs create mode 100644 sig/data_services_api/query_generator.rbs create mode 100644 sig/data_services_api/sapint_converter.rbs create mode 100644 sig/data_services_api/service.rbs create mode 100644 sig/data_services_api/service_exception.rbs create mode 100644 sig/data_services_api/value.rbs create mode 100644 sig/data_services_api/version.rbs diff --git a/sig/data_services_api/aspect.rbs b/sig/data_services_api/aspect.rbs new file mode 100644 index 0000000..1e081cd --- /dev/null +++ b/sig/data_services_api/aspect.rbs @@ -0,0 +1,29 @@ +module DataServicesApi + # Encapsulates a DSAPI Aspect + # This corresponds to one of the measures of the underlying data cube, which + # becomes an _aspect_ of the object description that the DSApi query encapsulates. + # See https://github.com/epimorphics/data-API/wiki/Data-Query-Syntax + class Aspect + @json: untyped + + @service: untyped + + attr_reader service: untyped + + def initialize: (untyped json, untyped service) -> void + + def id: () -> untyped + + alias uri id + + def method_missing: (untyped name) -> untyped + + def respond_to_missing?: (untyped name, ?untyped? _args) -> untyped + + def optional?: () -> untyped + + def multi_valued?: () -> untyped + + def range_type: () -> untyped + end +end diff --git a/sig/data_services_api/dataset.rbs b/sig/data_services_api/dataset.rbs new file mode 100644 index 0000000..052d1d7 --- /dev/null +++ b/sig/data_services_api/dataset.rbs @@ -0,0 +1,38 @@ +module DataServicesApi + # Encapsulates a single dataset from the data services API + class Dataset + @json: untyped + + @service: untyped + + @structure: untyped + + attr_reader service: untyped + + def initialize: (untyped json, untyped service) -> void + + def id: () -> untyped + + def method_missing: (untyped attribute) -> untyped + + def respond_to_missing?: (untyped attribute, ?untyped? _args) -> untyped + + def data_api: () -> untyped + + def dataset: () -> untyped + + def structure_api: () -> untyped + + def describe_api: () -> untyped + + def explain_api: () -> untyped + + def structure: () -> untyped + + def query: (untyped query) -> untyped + + def describe: (untyped uri) -> untyped + + def explain: (untyped query) -> untyped + end +end diff --git a/sig/data_services_api/dsapi_response_converter.rbs b/sig/data_services_api/dsapi_response_converter.rbs new file mode 100644 index 0000000..f1ea92b --- /dev/null +++ b/sig/data_services_api/dsapi_response_converter.rbs @@ -0,0 +1,28 @@ +# :nodoc: +module DataServicesApi + # Adapter-pattern to present a response from SapiNT in the same JSON structure + # that the old DsAPI was using + class DSAPIResponseConverter + @sapint_response: untyped + + @dataset_name: untyped + + @json_mode_compact: untyped + + def initialize: (untyped sapint_response, untyped dataset_name, ?bool json_mode_compact) -> void + + # Converts SAPINT returned JSON format to DSAPI returned JSON format + def to_dsapi_response: () -> untyped + + private + + def to_dsapi_item: (untyped sapint_item) -> untyped + + # Return different response formats based on the set JSON mode + def to_dsapi_json: (untyped sapint_key, untyped sapint_value) -> untyped + + def json_mode_compact: (untyped sapint_key, untyped sapint_value) -> (::Hash[untyped, untyped] | ::Hash[::String, untyped] | untyped) + + def json_mode_complete: (untyped sapint_key, untyped sapint_value) -> (::Hash[untyped, untyped] | ::Hash[::String, { "@value" => untyped }] | ::Hash[::String, ::Array[{ "@value" => untyped }]] | ::Hash[::String, ::Array[untyped]] | ::Hash[::String, untyped]) + end +end diff --git a/sig/data_services_api/query_generator.rbs b/sig/data_services_api/query_generator.rbs new file mode 100644 index 0000000..6fdb1a9 --- /dev/null +++ b/sig/data_services_api/query_generator.rbs @@ -0,0 +1,60 @@ +module DataServicesApi + # Generate Data Service API queries, and serialize as JSON + class QueryGenerator + @terms: untyped + + OPERATIONS: ::Array["@eq" | "@ge" | "@gt" | "@le" | "@lt"] + + attr_reader terms: untyped + + def initialize: (?untyped? initial_terms) -> void + + def to_json: (*untyped _args) -> untyped + + def compact_json: () -> untyped + + def eq: (untyped attribute, untyped value) -> untyped + + def eq_any_uri: (untyped attribute, untyped uris, ?::Hash[untyped, untyped] options) -> untyped + + def eq_any_value: (untyped attribute, untyped values, ?::Hash[untyped, untyped] options) -> untyped + + def ge: (untyped attribute, untyped value) -> untyped + + def gt: (untyped attribute, untyped value) -> untyped + + def le: (untyped attribute, untyped value) -> untyped + + def lt: (untyped attribute, untyped value) -> untyped + + def op: (untyped oper, untyped attribute, untyped value) -> untyped + + def sort: (untyped up_or_down, untyped attribute) -> untyped + + def search: (untyped pattern) -> untyped + + def search_property: (untyped property, untyped pattern, ?::Hash[untyped, untyped] options) -> untyped + + def search_aspect: (untyped aspect, untyped pattern) -> untyped + + def search_aspect_property: (untyped aspect, untyped property, untyped pattern, ?::Hash[untyped, untyped] options) -> untyped + + def matches: (untyped aspect, untyped pattern, ?::Hash[untyped, untyped] options) -> untyped + + def limit: (untyped lim) -> untyped + + def offset: (untyped num) -> untyped + + def count_only: () -> untyped + + private + + def relational: (untyped rel, untyped attribute, untyped value) -> untyped + + def eq_any: (untyped attribute, untyped values, untyped value_type, untyped options) -> untyped + + def as_typed_value: (untyped value) -> untyped + + def merge_terms: (untyped term) -> untyped + end +end diff --git a/sig/data_services_api/sapint_converter.rbs b/sig/data_services_api/sapint_converter.rbs new file mode 100644 index 0000000..2c78814 --- /dev/null +++ b/sig/data_services_api/sapint_converter.rbs @@ -0,0 +1,42 @@ +module DataServicesApi + # Transformer to convert queries using the DsAPI query DSL + # into an equivalent SapiNT query URL string + class SapiNTConverter + @dsapi_query: untyped + + def initialize: (untyped dsapi_query) -> void + + # Converts a DSAPI query to SAPINT query + def to_sapint_query: () -> untyped + + private + + def sapint_query: (untyped key, untyped value) -> untyped + + def sort: (untyped values) -> untyped + + def count: (untyped value) -> ({ "_count" => "@id" } | nil) + + def limit: (untyped value) -> { "_limit" => untyped } + + def offset: (untyped value) -> { "_offset" => untyped } + + def and_list: (untyped list) -> untyped + + def and_item: (untyped json_item) -> untyped + + def relation: (untyped relation, untyped attribute, untyped json) -> untyped + + def eq: (untyped attribute, untyped value) -> ::Hash[untyped, untyped] + + def oneof: (untyped attribute, untyped values) -> untyped + + def comparison: (untyped prefix, untyped attribute, untyped value) -> ::Hash[::String, untyped] + + def search: (untyped attribute, untyped value) -> ::Hash["searchPath" | ::String, untyped] + + def sanitize_search: (untyped search_text) -> untyped + + def remove_prefix: (untyped value) -> untyped + end +end diff --git a/sig/data_services_api/service.rbs b/sig/data_services_api/service.rbs new file mode 100644 index 0000000..c98064c --- /dev/null +++ b/sig/data_services_api/service.rbs @@ -0,0 +1,111 @@ +module DataServicesApi + # Denotes the encapsulated DataServicesAPI service + class Service + @instrumenter: untyped + + @faraday_logger: untyped + + @faraday_logger_options: untyped + + @url: untyped + + @connection_timeout: untyped + + @connection_failed_retry_options: untyped + + @timeout_retry_options: untyped + + attr_reader instrumenter: untyped + + attr_reader url: untyped + + DEFAULT_CONNECTION_FAILED_RETRY_OPTIONS: { max: 4, interval: ::Float, interval_randomness: ::Float, backoff_factor: 2, exceptions: ::Array[untyped] } + + DEFAULT_TIMEOUT_RETRY_OPTIONS: { max: 2, interval: ::Float, interval_randomness: ::Float, backoff_factor: 2, exceptions: ::Array[untyped] } + + DEFAULT_FARADAY_LOGGER_OPTIONS: { headers: false, bodies: false, errors: false, log_level: :debug } + + DEFAULT_CONNECTION_TIMEOUT_SECONDS: 600 + + def initialize: (?::Hash[untyped, untyped] config) -> void + + def datasets: () -> untyped + + def dataset: (untyped name) -> untyped + + def api_get_json: (untyped api, untyped params, ?::Hash[untyped, untyped] options) -> untyped + + def api_post_json: (untyped api, untyped json) -> untyped + + private + + # Get parsed JSON from the given URL + def get_json: (untyped http_url, untyped params, untyped options) -> untyped + + def get_from_api: (untyped http_url, untyped accept_headers, untyped params, untyped options) -> untyped + + def post_json: (untyped http_url, untyped json) -> untyped + + def post_to_api: (untyped http_url, untyped json) -> untyped + + # Perform an HTTP request against http_url, timing and instrumenting it consistently + # regardless of whether it succeeds, times out, fails to connect, or the remote API + # returns an error status or unparseable body. query_params, when given, is only used + # to report the query string on connection/service failures (a successful response + # reports its own resolved query string from the Faraday response itself) + def perform_request: (untyped http_url, ?untyped? query_params) { (untyped) -> untyped } -> untyped + + def create_http_connection: (untyped http_url) -> untyped + + # Add a retry_block to the given faraday-retry options that fires a + # retry.data_services_api notification before each retry attempt, preserving any + # retry_block the caller already configured + def with_retry_instrumentation: (untyped options) -> untyped + + # Fires 'retry.data_services_api' immediately before a retry attempt, with + # path (String), method (String, upcased), retry_count (Integer), + # exception, and will_retry_in (Float seconds until the retry fires - note + # this is seconds, not the milliseconds :duration uses on the other events + # below). exception is normally Faraday::TimeoutError or ConnectionFailed; + # it would be the synthetic Faraday::RetriableResponse if a status-code-based + # retry_statuses: were ever configured, which this gem doesn't set today. + def instrument_retry: (untyped env, untyped retry_count, untyped exception, untyped will_retry_in) -> untyped + + def as_http_api: (untyped api) -> untyped + + # Fires 'response.data_services_api' for every response that doesn't raise, + # with response (the raw Faraday::Response - path/query_string/method are + # all derivable from response.env.url/.method rather than duplicated as + # separate payload keys) and duration (Integer milliseconds, floor-divided + # by #elapsed_ms - sub-millisecond requests report 0, not a fractional value). + def instrument_response: (untyped response, untyped start_time) -> untyped + + # Fires 'connection_failure.data_services_api' on a network-level failure + # (after retries are exhausted): the request never got a response at all. + # Payload: exception (Faraday::TimeoutError or ConnectionFailed), path + # (String, no scheme/host/query), query_string (String or nil - nil for + # POST requests, which never pass query_params, and for GET requests with + # no params), duration (Integer milliseconds, see #instrument_response), + # and status (always the literal 503 - a fixed value, not derived from any + # actual response, since none was received). + def instrument_connection_failure: (untyped http_url, untyped query_params, untyped exception, untyped start_time) -> untyped + + # Fires 'service_exception.data_services_api' when the remote API responded + # but with an error status or an unparseable body. exception is always a + # ServiceException here: perform_request wraps every Faraday::Error (bad + # status, unparseable body, etc) into one before raising, so subscribers + # never see a raw Faraday::ResourceNotFound/ClientError/ServerError/ParsingError. + # Payload also has path and query_string (same shape/nilability as + # #instrument_connection_failure), duration (Integer milliseconds), and + # status (Integer or nil - nil if Faraday never associated a response with + # the error; see Faraday::Error#response_status, which can happen for some + # Faraday::ParsingError cases). + def instrument_service_exception: (untyped http_url, untyped query_params, untyped exception, untyped start_time) -> untyped + + # Return true if we're currently running in a Rails environment + def in_rails?: () -> untyped + + # The elapsed time in milliseconds since the given CLOCK_MONOTONIC microsecond timestamp + def elapsed_ms: (untyped start_time) -> untyped + end +end diff --git a/sig/data_services_api/service_exception.rbs b/sig/data_services_api/service_exception.rbs new file mode 100644 index 0000000..927acef --- /dev/null +++ b/sig/data_services_api/service_exception.rbs @@ -0,0 +1,19 @@ +module DataServicesApi + # A sub-type of RuntimeError for problems that arise when interacting with the + # DS API + class ServiceException < RuntimeError + @status: untyped + + @source: untyped + + @service_message: untyped + + attr_reader status: untyped + + attr_reader source: untyped + + attr_reader service_message: untyped + + def initialize: (untyped msg, untyped status, ?untyped? source, ?untyped? service_message) -> void + end +end diff --git a/sig/data_services_api/value.rbs b/sig/data_services_api/value.rbs new file mode 100644 index 0000000..482c8ee --- /dev/null +++ b/sig/data_services_api/value.rbs @@ -0,0 +1,22 @@ +module DataServicesApi + # Encapsulates a single value coming back from the API + class Value < Hash + def initialize: (?::Hash[untyped, untyped] base, ?::Hash[untyped, untyped] adds) -> void + + def value: () -> untyped + + def type: () -> untyped + + def uri: () -> untyped + + def with_uri: (untyped uri) -> untyped + + def self.uri: (untyped uri) -> untyped + + def with_typed_value: (untyped value, untyped type) -> untyped + + def with_year_month: (untyped year, untyped month) -> untyped + + def self.year_month: (untyped year, untyped month) -> untyped + end +end diff --git a/sig/data_services_api/version.rbs b/sig/data_services_api/version.rbs new file mode 100644 index 0000000..d8d1053 --- /dev/null +++ b/sig/data_services_api/version.rbs @@ -0,0 +1,12 @@ +# :nodoc: +module DataServicesApi + MAJOR: 2 + + MINOR: 0 + + PATCH: 0 + + SUFFIX: nil + + VERSION: ::String +end From 0417873480a966298f645df3cc08903fe0af1fc8 Mon Sep 17 00:00:00 2001 From: Alex Tucker Date: Thu, 6 Aug 2026 15:37:58 +0100 Subject: [PATCH 04/13] Add some types. --- sig/data_services_api/aspect.rbs | 20 +++++++++++++------- sig/data_services_api/dataset.rbs | 10 +++++----- sig/data_services_api/service.rbs | 14 +++++++------- sig/data_services_api/service_exception.rbs | 10 +++++----- sig/types.rbs | 4 ++++ 5 files changed, 34 insertions(+), 24 deletions(-) create mode 100644 sig/types.rbs diff --git a/sig/data_services_api/aspect.rbs b/sig/data_services_api/aspect.rbs index 1e081cd..5dd804c 100644 --- a/sig/data_services_api/aspect.rbs +++ b/sig/data_services_api/aspect.rbs @@ -4,26 +4,32 @@ module DataServicesApi # becomes an _aspect_ of the object description that the DSApi query encapsulates. # See https://github.com/epimorphics/data-API/wiki/Data-Query-Syntax class Aspect - @json: untyped + @json: json_hash @service: untyped attr_reader service: untyped - def initialize: (untyped json, untyped service) -> void + def initialize: (json_hash json, untyped service) -> void - def id: () -> untyped + def id: () -> uri alias uri id def method_missing: (untyped name) -> untyped - def respond_to_missing?: (untyped name, ?untyped? _args) -> untyped + def respond_to_missing?: (untyped name, ?untyped? _args) -> bool - def optional?: () -> untyped + def optional?: () -> bool - def multi_valued?: () -> untyped + def multi_valued?: () -> bool - def range_type: () -> untyped + def range_type: () -> uri + + def isOptional: () -> bool + + def isMultiValued: () -> bool + + def rangeType: () -> uri end end diff --git a/sig/data_services_api/dataset.rbs b/sig/data_services_api/dataset.rbs index 052d1d7..7845c89 100644 --- a/sig/data_services_api/dataset.rbs +++ b/sig/data_services_api/dataset.rbs @@ -1,7 +1,7 @@ module DataServicesApi # Encapsulates a single dataset from the data services API class Dataset - @json: untyped + @json: json_hash @service: untyped @@ -9,17 +9,17 @@ module DataServicesApi attr_reader service: untyped - def initialize: (untyped json, untyped service) -> void + def initialize: (json_hash json, untyped service) -> void - def id: () -> untyped + def id: () -> uri def method_missing: (untyped attribute) -> untyped - def respond_to_missing?: (untyped attribute, ?untyped? _args) -> untyped + def respond_to_missing?: (untyped attribute, ?untyped? _args) -> bool def data_api: () -> untyped - def dataset: () -> untyped + def dataset: () -> Dataset def structure_api: () -> untyped diff --git a/sig/data_services_api/service.rbs b/sig/data_services_api/service.rbs index c98064c..e437a85 100644 --- a/sig/data_services_api/service.rbs +++ b/sig/data_services_api/service.rbs @@ -7,9 +7,9 @@ module DataServicesApi @faraday_logger_options: untyped - @url: untyped + @url: uri - @connection_timeout: untyped + @connection_timeout: int @connection_failed_retry_options: untyped @@ -29,18 +29,18 @@ module DataServicesApi def initialize: (?::Hash[untyped, untyped] config) -> void - def datasets: () -> untyped + def datasets: () -> Array[Dataset] - def dataset: (untyped name) -> untyped + def dataset: (untyped name) -> Dataset - def api_get_json: (untyped api, untyped params, ?::Hash[untyped, untyped] options) -> untyped + def api_get_json: (untyped api, untyped params, ?::Hash[untyped, untyped] options) -> json_value - def api_post_json: (untyped api, untyped json) -> untyped + def api_post_json: (untyped api, untyped json) -> json_value private # Get parsed JSON from the given URL - def get_json: (untyped http_url, untyped params, untyped options) -> untyped + def get_json: (untyped http_url, untyped params, untyped options) -> json_value def get_from_api: (untyped http_url, untyped accept_headers, untyped params, untyped options) -> untyped diff --git a/sig/data_services_api/service_exception.rbs b/sig/data_services_api/service_exception.rbs index 927acef..453d45b 100644 --- a/sig/data_services_api/service_exception.rbs +++ b/sig/data_services_api/service_exception.rbs @@ -2,18 +2,18 @@ module DataServicesApi # A sub-type of RuntimeError for problems that arise when interacting with the # DS API class ServiceException < RuntimeError - @status: untyped + @status: int? @source: untyped - @service_message: untyped + @service_message: string? - attr_reader status: untyped + attr_reader status: int? attr_reader source: untyped - attr_reader service_message: untyped + attr_reader service_message: string? - def initialize: (untyped msg, untyped status, ?untyped? source, ?untyped? service_message) -> void + def initialize: (string? msg, int? status, ?untyped? source, ?untyped? service_message) -> void end end diff --git a/sig/types.rbs b/sig/types.rbs new file mode 100644 index 0000000..254264f --- /dev/null +++ b/sig/types.rbs @@ -0,0 +1,4 @@ +type uri = string +type json_value = + String | Integer | Float | TrueClass | FalseClass | NilClass | Array[json_value] | Hash[string, json_value] | uri +type json_hash = Hash[string, json_value] \ No newline at end of file From d83c5ea6bd36f1b1991679bed94805cca07d888d Mon Sep 17 00:00:00 2001 From: Alex Tucker Date: Thu, 6 Aug 2026 15:42:51 +0100 Subject: [PATCH 05/13] More types. --- sig/data_services_api/value.rbs | 20 ++++++++++---------- sig/data_services_api/version.rbs | 12 ++++-------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/sig/data_services_api/value.rbs b/sig/data_services_api/value.rbs index 482c8ee..0d6fbae 100644 --- a/sig/data_services_api/value.rbs +++ b/sig/data_services_api/value.rbs @@ -1,22 +1,22 @@ module DataServicesApi # Encapsulates a single value coming back from the API - class Value < Hash - def initialize: (?::Hash[untyped, untyped] base, ?::Hash[untyped, untyped] adds) -> void + class Value < Hash[string, json_value] + def initialize: (?::Hash[string, json_value] base, ?::Hash[string, json_value] adds) -> void - def value: () -> untyped + def value: () -> json_value - def type: () -> untyped + def type: () -> uri - def uri: () -> untyped + def uri: () -> uri - def with_uri: (untyped uri) -> untyped + def with_uri: (uri uri) -> Value - def self.uri: (untyped uri) -> untyped + def self.uri: (uri uri) -> Value - def with_typed_value: (untyped value, untyped type) -> untyped + def with_typed_value: (untyped value, untyped type) -> Value - def with_year_month: (untyped year, untyped month) -> untyped + def with_year_month: (untyped year, untyped month) -> Value - def self.year_month: (untyped year, untyped month) -> untyped + def self.year_month: (untyped year, untyped month) -> Value end end diff --git a/sig/data_services_api/version.rbs b/sig/data_services_api/version.rbs index d8d1053..8f16ca6 100644 --- a/sig/data_services_api/version.rbs +++ b/sig/data_services_api/version.rbs @@ -1,12 +1,8 @@ # :nodoc: module DataServicesApi - MAJOR: 2 - - MINOR: 0 - - PATCH: 0 - - SUFFIX: nil - + MAJOR: int + MINOR: int + PATCH: int + SUFFIX: string? VERSION: ::String end From b23314d0ed563c470c1c3e5d3a3f3212341b9d5d Mon Sep 17 00:00:00 2001 From: Alex Tucker Date: Thu, 6 Aug 2026 15:53:12 +0100 Subject: [PATCH 06/13] Ignore type warnings when we know the value should be a certain type. --- lib/data_services_api/aspect.rb | 2 +- lib/data_services_api/dataset.rb | 6 +++--- lib/data_services_api/service.rb | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/data_services_api/aspect.rb b/lib/data_services_api/aspect.rb index b603212..28a08b9 100644 --- a/lib/data_services_api/aspect.rb +++ b/lib/data_services_api/aspect.rb @@ -13,7 +13,7 @@ def initialize(json, service) @service = service end - def id + def id # steep:ignore MethodBodyTypeMismatch @json['@id'] end diff --git a/lib/data_services_api/dataset.rb b/lib/data_services_api/dataset.rb index 6f595cc..7038ba7 100644 --- a/lib/data_services_api/dataset.rb +++ b/lib/data_services_api/dataset.rb @@ -10,12 +10,12 @@ def initialize(json, service) @service = service end - def id + def id # steep:ignore MethodBodyTypeMismatch @json['@id'] end def method_missing(attribute) - respond_to_missing?(attribute) ? @json[attribute.to_s] : super + respond_to_missing?(attribute, nil) ? @json[attribute.to_s] : super end def respond_to_missing?(attribute, _args = nil) @@ -26,7 +26,7 @@ def data_api @json['data-api'] end - def dataset + def dataset # steep:ignore MethodBodyTypeMismatch @json['dataset'] end diff --git a/lib/data_services_api/service.rb b/lib/data_services_api/service.rb index d658760..4f7c8a2 100644 --- a/lib/data_services_api/service.rb +++ b/lib/data_services_api/service.rb @@ -130,7 +130,7 @@ def perform_request(http_url, method, query_params = nil) # rubocop:disable Metr def create_http_connection(http_url) # rubocop:disable Metrics/MethodLength Faraday.new(url: http_url) do |config| - config.options[:timeout] = @connection_timeout + config.options.timeout = @connection_timeout config.use Faraday::Request::UrlEncoded config.use Faraday::FollowRedirects::Middleware From 23d8273b8ed63673a1d8a18582f72ccc6db53d8f Mon Sep 17 00:00:00 2001 From: Alex Tucker Date: Thu, 6 Aug 2026 15:58:28 +0100 Subject: [PATCH 07/13] By default, JSON.parse() will not "symbolize" keys (see https://docs.ruby-lang.org/en/master/JSON.html#module-json-output-options). For consistency, use string keys in the Value hash. --- lib/data_services_api/value.rb | 14 +++++++------- test/data_services_api/value_test.rb | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/data_services_api/value.rb b/lib/data_services_api/value.rb index 479e010..31e0e4a 100644 --- a/lib/data_services_api/value.rb +++ b/lib/data_services_api/value.rb @@ -12,19 +12,19 @@ def initialize(base = {}, adds = {}) end def value - self[:@value] + self['@value'] end - def type - self[:@type] + def type # steep:ignore MethodBodyTypeMismatch + self['@type'] end - def uri - self[:@id] + def uri # steep:ignore MethodBodyTypeMismatch + self['@id'] end def with_uri(uri) - Value.new(self, '@id': uri) + Value.new(self, { '@id' => uri }) end def self.uri(uri) @@ -32,7 +32,7 @@ def self.uri(uri) end def with_typed_value(value, type) - Value.new(self, '@value': value, '@type': type) + Value.new(self, { '@value' => value, '@type' => type }) end def with_year_month(year, month) diff --git a/test/data_services_api/value_test.rb b/test/data_services_api/value_test.rb index 84d231a..d7600ca 100644 --- a/test/data_services_api/value_test.rb +++ b/test/data_services_api/value_test.rb @@ -18,24 +18,24 @@ it 'should specify a URI' do v1 = v.with_uri('http://foo/bar') _(v1.size).must_equal 1 - _(v1[:@id]).must_equal 'http://foo/bar' + _(v1['@id']).must_equal 'http://foo/bar' _(v1.uri).must_equal 'http://foo/bar' end it 'should have a factory shortcut for creating a URI value' do v = DataServicesApi::Value.uri('http://fubar.com') _(v.size).must_equal 1 - _(v[:@id]).must_equal 'http://fubar.com' + _(v['@id']).must_equal 'http://fubar.com' end it 'should specify type and value' do v1 = v.with_typed_value('foo', 'http://fakexsd.org/bar') _(v1.size).must_equal 2 - _(v1[:@value]).must_equal 'foo' + _(v1['@value']).must_equal 'foo' _(v1.value).must_equal 'foo' - _(v1[:@type]).must_equal 'http://fakexsd.org/bar' + _(v1['@type']).must_equal 'http://fakexsd.org/bar' _(v1.type).must_equal 'http://fakexsd.org/bar' end From adb85b599a06b4a2c7bcb2b7387abd9805a0ffa2 Mon Sep 17 00:00:00 2001 From: Alex Tucker Date: Thu, 6 Aug 2026 15:58:54 +0100 Subject: [PATCH 08/13] Update Gemfile.lock --- Gemfile.lock | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index 78bbca2..dbaa6c0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -17,9 +17,11 @@ GEM bigdecimal (3.3.1) byebug (12.0.0) cgi (0.5.2) + concurrent-ruby (1.3.8) crack (1.0.1) bigdecimal rexml + csv (3.3.6) docile (1.4.1) drb (2.2.3) excon (1.3.1) @@ -34,10 +36,17 @@ GEM net-http (~> 0.5) faraday-retry (2.4.0) faraday (~> 2.0) + ffi (1.17.4) + ffi (1.17.4-x86_64-darwin) + fileutils (1.8.0) hashdiff (1.2.1) json (2.20.0) language_server-protocol (3.17.0.5) lint_roller (1.1.0) + listen (3.10.0) + logger + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) logger (1.7.0) minitest (6.0.6) drb (~> 2.0) @@ -57,6 +66,13 @@ GEM racc (1.8.1) rainbow (3.1.1) rake (13.3.1) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) + rbs (4.1.2) + logger + prism (>= 1.6.0) + tsort regexp_parser (2.11.3) rexml (3.4.4) rubocop (1.81.7) @@ -75,12 +91,33 @@ GEM prism (~> 1.4) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) + securerandom (0.4.1) 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) + steep (2.0.0) + concurrent-ruby (>= 1.1.10) + csv (>= 3.0.9) + fileutils (>= 1.1.0) + json (>= 2.1.0) + language_server-protocol (>= 3.17.0.4, < 4.0) + listen (~> 3.0) + logger (>= 1.3.0) + parser (>= 3.2) + prism (>= 0.25.0) + rainbow (>= 2.2.2, < 4.0) + rbs (~> 4.0) + securerandom (>= 0.1) + strscan (>= 1.0.0) + terminal-table (>= 2, < 5) + uri (>= 0.12.0) + strscan (3.1.8) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) + tsort (0.2.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) @@ -109,6 +146,7 @@ DEPENDENCIES rake rubocop simplecov + steep vcr webmock From 18b7f144e99be575ec92d3b5a9d586f7e612ddd8 Mon Sep 17 00:00:00 2001 From: Alex Tucker Date: Thu, 6 Aug 2026 16:08:17 +0100 Subject: [PATCH 09/13] Add GH action to check types. --- .github/workflows/typecheck.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/typecheck.yml diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 0000000..32fae6a --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,20 @@ +name: typecheck.yml +on: + pull_request: +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + + - name: Install RBS collections + run: rbs collection install + + - name: Run type checker + run: bundle exec steep check --severity-level=error \ No newline at end of file From 7bb0bb62c122a34acd8ac9184a99243310362817 Mon Sep 17 00:00:00 2001 From: Alex Tucker Date: Thu, 6 Aug 2026 16:13:16 +0100 Subject: [PATCH 10/13] Add sig directory to Gem so that downstream apps can do type checking. --- data_services_api.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data_services_api.gemspec b/data_services_api.gemspec index 1a5cca6..22500fb 100644 --- a/data_services_api.gemspec +++ b/data_services_api.gemspec @@ -13,7 +13,7 @@ Gem::Specification.new do |spec| spec.license = 'MIT' spec.required_ruby_version = '>= 3.4' - spec.files = Dir.glob('lib/**/*', File::FNM_DOTMATCH) + ['LICENSE.txt', 'README.md'] + spec.files = Dir.glob('{lib/**/*,sig/**/*.rbs}', File::FNM_DOTMATCH) + ['LICENSE.txt', 'README.md'] spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.extra_rdoc_files = Dir['README.md', 'CHANGELOG.md', 'LICENSE.txt'] spec.require_paths = ['lib'] From 1e212b86d544634bc1dc8ee3486f21898b435940 Mon Sep 17 00:00:00 2001 From: Alex Tucker Date: Thu, 6 Aug 2026 16:17:13 +0100 Subject: [PATCH 11/13] Obligatory Rubocop commit --- Steepfile | 2 +- data_services_api.gemspec | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Steepfile b/Steepfile index a046de1..5e37350 100644 --- a/Steepfile +++ b/Steepfile @@ -3,4 +3,4 @@ target :lib do check 'lib' signature 'sig' -end \ No newline at end of file +end diff --git a/data_services_api.gemspec b/data_services_api.gemspec index 22500fb..b248099 100644 --- a/data_services_api.gemspec +++ b/data_services_api.gemspec @@ -13,7 +13,8 @@ Gem::Specification.new do |spec| spec.license = 'MIT' spec.required_ruby_version = '>= 3.4' - spec.files = Dir.glob('{lib/**/*,sig/**/*.rbs}', File::FNM_DOTMATCH) + ['LICENSE.txt', 'README.md'] + spec.files = Dir.glob('{lib/**/*,sig/**/*.rbs}', + File::FNM_DOTMATCH) + ['LICENSE.txt', 'README.md'] spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.extra_rdoc_files = Dir['README.md', 'CHANGELOG.md', 'LICENSE.txt'] spec.require_paths = ['lib'] From 9be2c0bc738de62c9c8a4ee711a09ece0e627fcd Mon Sep 17 00:00:00 2001 From: joescottdave Date: Tue, 18 Aug 2026 15:22:26 +0100 Subject: [PATCH 12/13] fix(types): declare instrument_request and correct perform_request signature --- sig/data_services_api/service.rbs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/sig/data_services_api/service.rbs b/sig/data_services_api/service.rbs index e437a85..5f9e2f2 100644 --- a/sig/data_services_api/service.rbs +++ b/sig/data_services_api/service.rbs @@ -53,7 +53,7 @@ module DataServicesApi # returns an error status or unparseable body. query_params, when given, is only used # to report the query string on connection/service failures (a successful response # reports its own resolved query string from the Faraday response itself) - def perform_request: (untyped http_url, ?untyped? query_params) { (untyped) -> untyped } -> untyped + def perform_request: (untyped http_url, untyped method, ?untyped? query_params) { (untyped) -> untyped } -> untyped def create_http_connection: (untyped http_url) -> untyped @@ -71,6 +71,12 @@ module DataServicesApi # retry_statuses: were ever configured, which this gem doesn't set today. def instrument_retry: (untyped env, untyped retry_count, untyped exception, untyped will_retry_in) -> untyped + # Fires 'request.data_services_api' immediately before a request is sent, with + # path (String), method (String), and query_string (String or nil - nil for + # POST requests, which never pass query_params, and for GET requests with + # no params). + def instrument_request: (untyped http_url, untyped method, untyped query_params) -> untyped + def as_http_api: (untyped api) -> untyped # Fires 'response.data_services_api' for every response that doesn't raise, From c153de85a3839e5b0dff842352940f5b3f6ee271 Mon Sep 17 00:00:00 2001 From: joescottdave Date: Wed, 19 Aug 2026 14:33:08 +0100 Subject: [PATCH 13/13] style: add new line to EOF --- .github/workflows/typecheck.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 32fae6a..5338e9c 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -17,4 +17,4 @@ jobs: run: rbs collection install - name: Run type checker - run: bundle exec steep check --severity-level=error \ No newline at end of file + run: bundle exec steep check --severity-level=error