diff --git a/README.md b/README.md index e79cf7c..fc72c83 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ accurate and fully typed. - [API Key](#api-key) - [Personal Access Token](#personal-access-token) - [Action Attempts](#action-attempts) + - [Setting a value to null](#setting-a-value-to-null) - [Pagination](#pagination) - [Manually fetch pages with the next_page_cursor](#manually-fetch-pages-with-the-next_page_cursor) - [Resume pagination](#resume-pagination) @@ -43,6 +44,7 @@ accurate and fully typed. - [Configuring the Faraday Client](#configuring-the-faraday-client) - [Using the Faraday Client](#using-the-faraday-client) - [Overriding the Client](#overriding-the-client) + - [Serializing URL search params](#serializing-url-search-params) - [Development and Testing](#development-and-testing) - [Quickstart](#quickstart) - [Source code](#source-code) @@ -226,6 +228,35 @@ rescue Seam::ActionAttemptTimeoutError end ``` +### Setting a value to null + +The Seam API distinguishes three states for an updatable parameter: +omitted (leave the stored value unchanged), null (unset the stored value), +and a value (set it). + +Ruby's `nil` means omitted. +The SDK removes `nil` parameters from the request entirely, +so passing `nil` never unsets a value. +To unset a value, pass the `Seam::NULL` sentinel, +which the SDK sends as JSON `null` in request bodies +and as an empty value in query strings: + +```ruby +require "seam" + +seam = Seam.new + +# Leaves ends_at unchanged. +seam.access_grants.update(access_grant_id: access_grant_id, ends_at: nil) + +# Unsets ends_at so the grant no longer expires. +seam.access_grants.update(access_grant_id: access_grant_id, ends_at: Seam::NULL) +``` + +Only pass `Seam::NULL` for parameters the API documents as nullable. +Generated methods document nullable parameters +with `Seam::Null` in their `@param` types, e.g. `[String, Seam::Null, nil]`. + ### Pagination Some Seam API endpoints that return lists of resources support pagination. @@ -489,6 +520,61 @@ devices = seam.client.get("/devices/list").body["devices"] A Faraday compatible client may be provided to create a `Seam` instance. This API is used internally and is not directly supported. +#### Serializing URL search params + +The Seam API parses URL search params as complex types. +If you call it with your own HTTP client, +`Seam.serialize_url_search_params` is exported for that purpose. +The `_strict=true` parameter is added to any non-empty query +so the Seam API uses strict, schema-aware parsing. +A query with no serializable params remains empty. + +```ruby +require "net/http" +require "seam" + +uri = URI("https://connect.getseam.com/devices/list") +uri.query = Seam.serialize_url_search_params({device_ids: ["device1", "device2"]}) + +Net::HTTP.get(uri, {"Authorization" => "Bearer your-api-key"}) +``` + +The serialization defines the name and value of each search param, +where every value is a string. +`Seam::UrlSearchParams` holds those pairs and renders the query string, +as [URLSearchParams] does for the [reference implementation]: + +```ruby +require "seam" + +search_params = Seam::UrlSearchParams.new + +Seam.update_url_search_params(search_params, {device_ids: ["device1", "device2"]}) + +search_params.to_a +# => [["device_ids", "device1"], ["device_ids", "device2"], ["_strict", "true"]] + +search_params.to_s +# => "device_ids=device1&device_ids=device2&_strict=true" +``` + +Pass either the query string or the pairs to your HTTP client. +A client may percent-encode a few characters differently +than `URLSearchParams` does, +which the Seam API reads as the same params either way. + +A param set to `nil` is omitted, +while a param set to `Seam::NULL` is serialized to an empty value, +which the Seam API reads as null, +as described in [Setting a value to null](#setting-a-value-to-null). +A param that cannot be represented raises a `Seam::UnserializableParamError`. + +The Seam API parses these params with the corresponding [parser]. + +[URLSearchParams]: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams +[reference implementation]: https://github.com/seamapi/url-search-params-serializer +[parser]: https://github.com/seamapi/url-search-params-parser + ## Development and Testing ### Quickstart diff --git a/codegen/lib/handlebars-helpers.ts b/codegen/lib/handlebars-helpers.ts index 6444abb..b686580 100644 --- a/codegen/lib/handlebars-helpers.ts +++ b/codegen/lib/handlebars-helpers.ts @@ -94,10 +94,12 @@ export const rubyParameterType = (parameter: Parameter): string => { parameter.format, parameter.format === 'number' && parameter.isInt, ) - return nullable(type, { - isOptional: !parameter.isRequired, - isNullable: parameter.isNullable, - }) + // Only a nullable parameter accepts the Seam::NULL sentinel, and only an + // optional parameter accepts nil. + const union = [type] + if (parameter.isNullable) union.push('Seam::Null') + if (!parameter.isRequired) union.push('nil') + return union.join(', ') } export const rubyParamDoc = ( diff --git a/lib/seam.rb b/lib/seam.rb index 66205c3..aa2f2f6 100644 --- a/lib/seam.rb +++ b/lib/seam.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require_relative "seam/null" +require_relative "seam/url_search_params" +require_relative "seam/strict_url_search_params_serializer" require_relative "seam/http" require_relative "seam/http_without_workspace" require_relative "seam/webhook" diff --git a/lib/seam/null.rb b/lib/seam/null.rb new file mode 100644 index 0000000..3900efd --- /dev/null +++ b/lib/seam/null.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "singleton" + +module Seam + # The type of the {Seam::NULL} sentinel. + class Null + include Singleton + + def to_s + "NULL" + end + + def inspect + "NULL" + end + end + + # Sentinel for an explicit JSON null: +nil+ omits a parameter, +Seam::NULL+ + # unsets its stored value. Only for parameters the API documents as + # nullable. + NULL = Null.instance +end diff --git a/lib/seam/request.rb b/lib/seam/request.rb index 480d47e..ba87950 100644 --- a/lib/seam/request.rb +++ b/lib/seam/request.rb @@ -5,6 +5,7 @@ require_relative "defaults" require_relative "version" require_relative "paginator" +require_relative "strict_url_search_params_serializer" module Seam module Http @@ -16,7 +17,11 @@ def self.create_faraday_client(endpoint, auth_headers, faraday_options = {}, far default_options = { url: endpoint, headers: auth_headers.merge(default_headers), - request: {timeout: timeout, open_timeout: timeout} + request: { + timeout: timeout, + open_timeout: timeout, + params_encoder: UrlSearchParamsEncoder + } } options = deep_merge(default_options, faraday_options) @@ -33,6 +38,7 @@ def self.create_faraday_client(endpoint, auth_headers, faraday_options = {}, far faraday_retry_options = default_faraday_retry_options.merge(faraday_retry_options) Faraday.new(options) do |builder| + builder.use ReplaceNullMiddleware builder.request :json builder.use Seam::PaginationMiddleware builder.response :json @@ -97,6 +103,14 @@ def seam_api_error_response?(env) end end + class ReplaceNullMiddleware < Faraday::Middleware + def on_request(env) + return unless env.body.is_a?(Hash) || env.body.is_a?(Array) + + env.body = Seam.replace_null(env.body) + end + end + def self.deep_merge(hash1, hash2) result = hash1.dup hash2.each do |key, value| @@ -111,5 +125,39 @@ def self.deep_merge(hash1, hash2) private_class_method :deep_merge end + + module UrlSearchParamsEncoder + # Pairs decoded from a query string already present in the request + # path, passed through {encode} verbatim rather than re-serialized. + Decoded = Struct.new(:values) + + def self.encode(params) + search_params = Seam::UrlSearchParams.new + map_params = {} + + params.each do |name, value| + if value.is_a?(Decoded) + value.values.each { |element| search_params.append(name, element) } + else + map_params[name] = value + end + end + + return search_params.to_s if map_params.empty? + + Seam.update_url_search_params(search_params, map_params) + search_params.to_s + end + + # Called by Faraday when a request path carries its own query string. + def self.decode(query) + return {} if query.nil? || query.empty? + + pairs = URI.decode_www_form(query.encode(Encoding::UTF_8)) + pairs.each_with_object({}) do |(name, value), decoded| + (decoded[name] ||= Decoded.new([])).values << value + end + end + end end end diff --git a/lib/seam/routes/access_codes.rb b/lib/seam/routes/access_codes.rb index 3e7dab2..af12abb 100644 --- a/lib/seam/routes/access_codes.rb +++ b/lib/seam/routes/access_codes.rb @@ -129,7 +129,7 @@ def get(access_code_id: nil, code: nil, device_id: nil) # @param customer_key [String, nil] Customer key for which you want to list access codes. # @param device_id [String, nil] ID of the device for which you want to list access codes. Specify `device_id`, `access_code_ids`, `access_method_id`, `access_grant_id`, or `access_grant_key`. # @param limit [Float, nil] Numerical limit on the number of access codes to return. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned access codes to include all records that satisfy a partial match using `name`, `code` or `access_code_id`. # @param user_identifier_key [String, nil] Your user ID for the user by which to filter access codes. # @return [Seam::Resources::AccessCode] OK diff --git a/lib/seam/routes/access_codes_unmanaged.rb b/lib/seam/routes/access_codes_unmanaged.rb index 3ef9469..3f6c04a 100644 --- a/lib/seam/routes/access_codes_unmanaged.rb +++ b/lib/seam/routes/access_codes_unmanaged.rb @@ -53,7 +53,7 @@ def get(access_code_id: nil, code: nil, device_id: nil) # Returns a list of all [unmanaged access codes](https://docs.seam.co/low-level-apis/smart-locks/access-codes/migrating-existing-access-codes). # @param device_id [String] ID of the device for which you want to list unmanaged access codes. # @param limit [Float, nil] Numerical limit on the number of unmanaged access codes to return. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned access codes to include all records that satisfy a partial match using `name`, `code` or `access_code_id`. # @param user_identifier_key [String, nil] Your user ID for the user by which to filter unmanaged access codes. # @return [Seam::Resources::UnmanagedAccessCode] OK diff --git a/lib/seam/routes/access_grants.rb b/lib/seam/routes/access_grants.rb index f0a1061..fa2a51f 100644 --- a/lib/seam/routes/access_grants.rb +++ b/lib/seam/routes/access_grants.rb @@ -20,12 +20,12 @@ def unmanaged # @param acs_entrance_ids [Array, nil] Set of IDs of the [entrances](https://docs.seam.co/api/acs/systems/list) to which access is being granted. # @param customization_profile_id [String, nil] ID of the customization profile to apply to the Access Grant and its access methods. # @param device_ids [Array, nil] Set of IDs of the [devices](https://docs.seam.co/api/devices/list) to which access is being granted. - # @param ends_at [String, nil] Date and time at which the validity of the new grant ends, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Must be a time in the future and after `starts_at`. + # @param ends_at [String, Seam::Null, nil] Date and time at which the validity of the new grant ends, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Must be a time in the future and after `starts_at`. # @param location [Hash, nil] # @deprecated location: Create a space first, then reference it using `space_ids`. # @param location_ids [Array, nil] # @deprecated location_ids: Use `space_ids`. - # @param name [String, nil] Name for the access grant. + # @param name [String, Seam::Null, nil] Name for the access grant. # @param reservation_key [String, nil] Reservation key for the access grant. # @param space_ids [Array, nil] Set of IDs of existing spaces to which access is being granted. # @param space_keys [Array, nil] Set of keys of existing spaces to which access is being granted. @@ -79,7 +79,7 @@ def get_related(access_grant_ids: nil, access_grant_keys: nil, exclude: nil, inc # Gets an Access Grant. # @param access_code_id [String, nil] ID of the access code by which you want to filter the list of Access Grants. # @param access_grant_ids [Array, nil] IDs of the access grants to retrieve. - # @param access_grant_key [String, nil] Filter Access Grants by access_grant_key. Use null to filter for Access Grants without an access_grant_key. + # @param access_grant_key [String, Seam::Null, nil] Filter Access Grants by access_grant_key. Use null to filter for Access Grants without an access_grant_key. # @param acs_entrance_id [String, nil] ID of the entrance by which you want to filter the list of Access Grants. # @param acs_system_id [String, nil] ID of the access system by which you want to filter the list of Access Grants. # @param customer_key [String, nil] Customer key for which you want to list access grants. @@ -87,7 +87,7 @@ def get_related(access_grant_ids: nil, access_grant_keys: nil, exclude: nil, inc # @param limit [Float, nil] Numerical limit on the number of access grants to return. # @param location_id [String, nil] # @deprecated location_id: Use `space_id`. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param reservation_key [String, nil] Filter Access Grants by reservation_key. # @param space_id [String, nil] ID of the space by which you want to filter the list of Access Grants. # @param user_identity_id [String, nil] ID of user identity by which you want to filter the list of Access Grants. @@ -111,8 +111,8 @@ def request_access_methods(access_grant_id:, requested_access_methods:) # Updates an existing Access Grant's time window. # @param access_grant_id [String, nil] ID of the Access Grant to update. Provide either `access_grant_id` or `access_grant_key`. # @param access_grant_key [String, nil] Key of the Access Grant to update. Provide either `access_grant_id` or `access_grant_key`. - # @param ends_at [Time, nil] Date and time at which the validity of the grant ends, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Must be a time in the future and after `starts_at`. - # @param name [String, nil] Display name for the access grant. + # @param ends_at [Time, Seam::Null, nil] Date and time at which the validity of the grant ends, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Must be a time in the future and after `starts_at`. + # @param name [String, Seam::Null, nil] Display name for the access grant. # @param starts_at [Time, nil] Date and time at which the validity of the grant starts, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. # @return [nil] OK def update(access_grant_id: nil, access_grant_key: nil, ends_at: nil, name: nil, starts_at: nil) diff --git a/lib/seam/routes/access_grants_unmanaged.rb b/lib/seam/routes/access_grants_unmanaged.rb index 52742a0..03750d0 100644 --- a/lib/seam/routes/access_grants_unmanaged.rb +++ b/lib/seam/routes/access_grants_unmanaged.rb @@ -21,7 +21,7 @@ def get(access_grant_id:) # @param acs_entrance_id [String, nil] ID of the entrance by which you want to filter the list of unmanaged Access Grants. # @param acs_system_id [String, nil] ID of the access system by which you want to filter the list of unmanaged Access Grants. # @param limit [Float, nil] Numerical limit on the number of unmanaged access grants to return. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param reservation_key [String, nil] Filter unmanaged Access Grants by reservation_key. # @param user_identity_id [String, nil] ID of user identity by which you want to filter the list of unmanaged Access Grants. # @return [Seam::Resources::UnmanagedAccessGrant] OK diff --git a/lib/seam/routes/access_methods.rb b/lib/seam/routes/access_methods.rb index 3c01c5d..46fd0a5 100644 --- a/lib/seam/routes/access_methods.rb +++ b/lib/seam/routes/access_methods.rb @@ -80,7 +80,7 @@ def get_related(access_method_ids:, exclude: nil, include: nil) # @param acs_entrance_id [String, nil] ID of the entrance for which you want to retrieve all access methods that grant access to it. # @param device_id [String, nil] ID of the device by which to filter the returned access methods. Must be combined with `access_grant_id`, `access_grant_key`, or `acs_entrance_id`. # @param limit [Integer, nil] Maximum number of records to return per page. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param space_id [String, nil] ID of the space by which to filter the returned access methods. Must be combined with `access_grant_id`, `access_grant_key`, or `acs_entrance_id`. # @return [Seam::Resources::AccessMethod] OK def list(access_code_id: nil, access_grant_id: nil, access_grant_key: nil, acs_entrance_id: nil, device_id: nil, limit: nil, page_cursor: nil, space_id: nil) diff --git a/lib/seam/routes/acs_credentials.rb b/lib/seam/routes/acs_credentials.rb index a66f248..5c70928 100644 --- a/lib/seam/routes/acs_credentials.rb +++ b/lib/seam/routes/acs_credentials.rb @@ -65,7 +65,7 @@ def get(acs_credential_id:) # @param created_before [Time, nil] Date and time, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format, before which events to return were created. # @param is_multi_phone_sync_credential [Boolean, nil] Indicates whether you want to retrieve only multi-phone sync credentials or non-multi-phone sync credentials. # @param limit [Float, nil] Number of credentials to return. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned credentials to include all records that satisfy a partial match using `display_name`, `code`, `card_number`, `acs_user_id` or `acs_credential_id`. # @return [Seam::Resources::AcsCredential] OK def list(acs_user_id: nil, acs_system_id: nil, user_identity_id: nil, created_before: nil, is_multi_phone_sync_credential: nil, limit: nil, page_cursor: nil, search: nil) diff --git a/lib/seam/routes/acs_encoders.rb b/lib/seam/routes/acs_encoders.rb index 7da5c6b..b7f84e4 100644 --- a/lib/seam/routes/acs_encoders.rb +++ b/lib/seam/routes/acs_encoders.rb @@ -41,7 +41,7 @@ def get(acs_encoder_id:) # @param acs_system_ids [Array, nil] IDs of the access systems for which you want to retrieve all encoders. # @param acs_encoder_ids [Array, nil] IDs of the encoders that you want to retrieve. # @param limit [Float, nil] Number of encoders to return. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @return [Seam::Resources::AcsEncoder] OK def list(acs_system_id: nil, acs_system_ids: nil, acs_encoder_ids: nil, limit: nil, page_cursor: nil) res = @client.post("/acs/encoders/list", {acs_system_id: acs_system_id, acs_system_ids: acs_system_ids, acs_encoder_ids: acs_encoder_ids, limit: limit, page_cursor: page_cursor}.compact) diff --git a/lib/seam/routes/acs_entrances.rb b/lib/seam/routes/acs_entrances.rb index 5043518..6fb4f2b 100644 --- a/lib/seam/routes/acs_entrances.rb +++ b/lib/seam/routes/acs_entrances.rb @@ -38,9 +38,9 @@ def grant_access(acs_entrance_id:, acs_user_id: nil, user_identity_id: nil) # @param connected_account_id [String, nil] ID of the connected account for which you want to retrieve all entrances. # @param customer_key [String, nil] Customer key for which you want to list entrances. # @param limit [Integer, nil] Maximum number of records to return per page. - # @param location_id [String, nil] + # @param location_id [String, Seam::Null, nil] # @deprecated location_id: Use `space_id`. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned entrances to include all records that satisfy a partial match using `display_name`. # @param space_id [String, nil] ID of the space for which you want to list entrances. # @return [Seam::Resources::AcsEntrance] OK diff --git a/lib/seam/routes/acs_users.rb b/lib/seam/routes/acs_users.rb index a8d9f0b..f8e75f9 100644 --- a/lib/seam/routes/acs_users.rb +++ b/lib/seam/routes/acs_users.rb @@ -69,7 +69,7 @@ def get(acs_user_id: nil, acs_system_id: nil, user_identity_id: nil) # @param acs_system_id [String, nil] ID of the `acs_system` for which you want to retrieve all access system users. # @param created_before [Time, nil] Timestamp by which to limit returned access system users. Returns users created before this timestamp. # @param limit [Integer, nil] Maximum number of records to return per page. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned access system users to include all records that satisfy a partial match using `full_name`, `phone_number`, `email_address`, `acs_user_id`, `user_identity_id`, `user_identity_full_name` or `user_identity_phone_number`. # @param user_identity_email_address [String, nil] Email address of the user identity for which you want to retrieve all access system users. # @param user_identity_id [String, nil] ID of the user identity for which you want to retrieve all access system users. @@ -153,7 +153,7 @@ def unsuspend(acs_system_id: nil, acs_user_id: nil, user_identity_id: nil) end # Updates the properties of a specified [access system user](https://docs.seam.co/low-level-apis/access-systems/user-management). - # @param access_schedule [Hash, nil] `starts_at` and `ends_at` timestamps for the access system user's access. If you specify an `access_schedule`, you may include both `starts_at` and `ends_at`. If you omit `starts_at`, it defaults to the current time. `ends_at` is optional and must be a time in the future and after `starts_at`. + # @param access_schedule [Hash, Seam::Null, nil] `starts_at` and `ends_at` timestamps for the access system user's access. If you specify an `access_schedule`, you may include both `starts_at` and `ends_at`. If you omit `starts_at`, it defaults to the current time. `ends_at` is optional and must be a time in the future and after `starts_at`. # @param acs_system_id [String, nil] ID of the access system that you want to update. You can only provide acs_system_id with user_identity_id. # @param acs_user_id [String, nil] ID of the access system user that you want to update. You can only provide acs_user_id or user_identity_id. # @param email [String, nil] diff --git a/lib/seam/routes/action_attempts.rb b/lib/seam/routes/action_attempts.rb index 31cc10d..69b4eac 100644 --- a/lib/seam/routes/action_attempts.rb +++ b/lib/seam/routes/action_attempts.rb @@ -25,7 +25,7 @@ def get(action_attempt_id:, wait_for_action_attempt: nil) # @param action_attempt_ids [Array, nil] IDs of the action attempts that you want to retrieve. # @param device_id [String, nil] ID of the device to filter action attempts by. # @param limit [Integer, nil] Maximum number of records to return per page. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @return [Seam::Resources::ActionAttempt] OK def list(action_attempt_ids: nil, device_id: nil, limit: nil, page_cursor: nil) res = @client.post("/action_attempts/list", {action_attempt_ids: action_attempt_ids, device_id: device_id, limit: limit, page_cursor: page_cursor}.compact) diff --git a/lib/seam/routes/connect_webviews.rb b/lib/seam/routes/connect_webviews.rb index cff03d2..02090b8 100644 --- a/lib/seam/routes/connect_webviews.rb +++ b/lib/seam/routes/connect_webviews.rb @@ -58,7 +58,7 @@ def get(connect_webview_id:) # @param custom_metadata_has [Hash, nil] Custom metadata pairs by which you want to [filter Connect Webviews](https://docs.seam.co/core-concepts/connect-webviews/filtering-connect-webviews-by-custom-metadata). Returns Connect Webviews with `custom_metadata` that contains all of the provided key:value pairs. # @param customer_key [String, nil] Customer key for which you want to list connect webviews. # @param limit [Float, nil] Maximum number of records to return per page. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned Connect Webviews to include all records that satisfy a partial match using `connect_webview_id`, `accepted_providers`, `custom_metadata`, or `customer_key`. # @param user_identifier_key [String, nil] Your user ID for the user by which you want to filter Connect Webviews. # @return [Seam::Resources::ConnectWebview] OK diff --git a/lib/seam/routes/connected_accounts.rb b/lib/seam/routes/connected_accounts.rb index 273256d..69228b0 100644 --- a/lib/seam/routes/connected_accounts.rb +++ b/lib/seam/routes/connected_accounts.rb @@ -43,7 +43,7 @@ def get(connected_account_id: nil, email: nil) # @param custom_metadata_has [Hash, nil] Custom metadata pairs by which you want to filter connected accounts. Returns connected accounts with `custom_metadata` that contains all of the provided key:value pairs. # @param customer_key [String, nil] Customer key by which you want to filter connected accounts. # @param limit [Integer, nil] Maximum number of records to return per page. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned connected accounts to include all records that satisfy a partial match using `connected_account_id`, `account_type`, `customer_key`, `custom_metadata`, `user_identifier.username`, `user_identifier.email` or `user_identifier.phone`. # @param space_id [String, nil] ID of the space by which you want to filter connected accounts. # @param user_identifier_key [String, nil] Your user ID for the user by which you want to filter connected accounts. diff --git a/lib/seam/routes/devices.rb b/lib/seam/routes/devices.rb index 1d64d41..b017281 100644 --- a/lib/seam/routes/devices.rb +++ b/lib/seam/routes/devices.rb @@ -44,10 +44,10 @@ def get(device_id: nil, name: nil) # @param device_types [Array, nil] Array of device types for which you want to list devices. # @param limit [Float, nil] Numerical limit on the number of devices to return. # @param manufacturer [String, nil] Manufacturer for which you want to list devices. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned devices to include all records that satisfy a partial match using `device_id` (full or partial UUID prefix, minimum 4 characters), `connected_account_id`, `display_name`, `custom_metadata` or `location.location_name`. # @param space_id [String, nil] ID of the space for which you want to list devices. - # @param unstable_location_id [String, nil] + # @param unstable_location_id [String, Seam::Null, nil] # @deprecated unstable_location_id: Use `space_id`. # @param user_identifier_key [String, nil] Your own internal user ID for the user for which you want to list devices. # @return [Seam::Resources::Device] OK @@ -86,7 +86,7 @@ def report_provider_metadata(devices:) # @param backup_access_code_pool_enabled [Boolean, nil] Indicates whether the device's [backup access code pool](https://docs.seam.co/low-level-apis/smart-locks/access-codes/backup-access-codes) is enabled. Set to `false` to disable the pool: Seam stops refilling it and removes any backup codes that have not yet been pulled into active use. # @param custom_metadata [Hash, nil] Custom metadata that you want to associate with the device. Supports up to 50 JSON key:value pairs. [Adding custom metadata to a device](https://docs.seam.co/core-concepts/devices/adding-custom-metadata-to-a-device) enables you to store custom information, like customer details or internal IDs from your application. Then, you can [filter devices by the desired metadata](https://docs.seam.co/core-concepts/devices/filtering-devices-by-custom-metadata). # @param is_managed [Boolean, nil] Indicates whether the device is managed. To unmanage a device, set `is_managed` to `false`. - # @param name [String, nil] Name for the device. + # @param name [String, Seam::Null, nil] Name for the device. # @param properties [Hash, nil] # @return [nil] OK def update(device_id:, backup_access_code_pool_enabled: nil, custom_metadata: nil, is_managed: nil, name: nil, properties: nil) diff --git a/lib/seam/routes/devices_unmanaged.rb b/lib/seam/routes/devices_unmanaged.rb index 8f61a77..143832e 100644 --- a/lib/seam/routes/devices_unmanaged.rb +++ b/lib/seam/routes/devices_unmanaged.rb @@ -39,7 +39,7 @@ def get(device_id: nil, name: nil) # @param device_types [Array, nil] Array of device types for which you want to list devices. # @param limit [Float, nil] Numerical limit on the number of devices to return. # @param manufacturer [String, nil] Manufacturer for which you want to list devices. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned devices to include all records that satisfy a partial match using `device_id` (full or partial UUID prefix, minimum 4 characters), `connected_account_id`, `display_name`, `custom_metadata` or `location.location_name`. # @return [Seam::Resources::UnmanagedDevice] OK def list(connect_webview_id: nil, connected_account_id: nil, connected_account_ids: nil, created_before: nil, customer_key: nil, device_ids: nil, device_type: nil, device_types: nil, limit: nil, manufacturer: nil, page_cursor: nil, search: nil) diff --git a/lib/seam/routes/spaces.rb b/lib/seam/routes/spaces.rb index 7f73e93..c362696 100644 --- a/lib/seam/routes/spaces.rb +++ b/lib/seam/routes/spaces.rb @@ -95,7 +95,7 @@ def get_related(exclude: nil, include: nil, space_ids: nil, space_keys: nil) # Returns a list of all spaces. # @param customer_key [String, nil] Customer key for which you want to list spaces. # @param limit [Float, nil] Maximum number of records to return per page. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned spaces to include all records that satisfy a partial match using `name`, `space_key`, or `customer_key`. # @param space_key [String, nil] Filter spaces by space_key. # @return [Seam::Resources::Space] OK diff --git a/lib/seam/routes/thermostats.rb b/lib/seam/routes/thermostats.rb index 89e77ae..61948f6 100644 --- a/lib/seam/routes/thermostats.rb +++ b/lib/seam/routes/thermostats.rb @@ -60,7 +60,7 @@ def cool(device_id:, cooling_set_point_celsius: nil, cooling_set_point_fahrenhei # @param hvac_mode_setting [String, nil] Desired [HVAC mode](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/hvac-mode) setting, such as `heat`, `cool`, `heat_cool`, or `off`. # @param manual_override_allowed [Boolean, nil] Indicates whether a person at the thermostat or using the API can change the thermostat's settings. # @deprecated manual_override_allowed: Use 'thermostat_schedule.is_override_allowed' - # @param name [String, nil] User-friendly name to identify the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). + # @param name [String, Seam::Null, nil] User-friendly name to identify the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). # @return [nil] OK def create_climate_preset(climate_preset_key:, device_id:, climate_preset_mode: nil, cooling_set_point_celsius: nil, cooling_set_point_fahrenheit: nil, ecobee_metadata: nil, fan_mode_setting: nil, heating_set_point_celsius: nil, heating_set_point_fahrenheit: nil, hvac_mode_setting: nil, manual_override_allowed: nil, name: nil) @client.post("/thermostats/create_climate_preset", {climate_preset_key: climate_preset_key, device_id: device_id, climate_preset_mode: climate_preset_mode, cooling_set_point_celsius: cooling_set_point_celsius, cooling_set_point_fahrenheit: cooling_set_point_fahrenheit, ecobee_metadata: ecobee_metadata, fan_mode_setting: fan_mode_setting, heating_set_point_celsius: heating_set_point_celsius, heating_set_point_fahrenheit: heating_set_point_fahrenheit, hvac_mode_setting: hvac_mode_setting, manual_override_allowed: manual_override_allowed, name: name}.compact) @@ -173,10 +173,10 @@ def set_hvac_mode(device_id:, hvac_mode_setting:, cooling_set_point_celsius: nil # Sets a [temperature threshold](https://docs.seam.co/capability-guides/thermostats/setting-and-monitoring-temperature-thresholds) for a specified thermostat. Seam emits a `thermostat.temperature_threshold_exceeded` event and adds a warning on a thermostat if it reports a temperature outside the threshold range. # @param device_id [String] ID of the thermostat device for which you want to set a temperature threshold. - # @param lower_limit_celsius [Float, nil] Lower temperature limit in in °C. Seam alerts you if the reported temperature is lower than this value. You can specify either `lower_limit` but not both. - # @param lower_limit_fahrenheit [Float, nil] Lower temperature limit in in °F. Seam alerts you if the reported temperature is lower than this value. You can specify either `lower_limit` but not both. - # @param upper_limit_celsius [Float, nil] Upper temperature limit in in °C. Seam alerts you if the reported temperature is higher than this value. You can specify either `upper_limit` but not both. - # @param upper_limit_fahrenheit [Float, nil] Upper temperature limit in in °C. Seam alerts you if the reported temperature is higher than this value. You can specify either `upper_limit` but not both. + # @param lower_limit_celsius [Float, Seam::Null, nil] Lower temperature limit in in °C. Seam alerts you if the reported temperature is lower than this value. You can specify either `lower_limit` but not both. + # @param lower_limit_fahrenheit [Float, Seam::Null, nil] Lower temperature limit in in °F. Seam alerts you if the reported temperature is lower than this value. You can specify either `lower_limit` but not both. + # @param upper_limit_celsius [Float, Seam::Null, nil] Upper temperature limit in in °C. Seam alerts you if the reported temperature is higher than this value. You can specify either `upper_limit` but not both. + # @param upper_limit_fahrenheit [Float, Seam::Null, nil] Upper temperature limit in in °C. Seam alerts you if the reported temperature is higher than this value. You can specify either `upper_limit` but not both. # @return [nil] OK def set_temperature_threshold(device_id:, lower_limit_celsius: nil, lower_limit_fahrenheit: nil, upper_limit_celsius: nil, upper_limit_fahrenheit: nil) @client.patch("/thermostats/set_temperature_threshold", {device_id: device_id, lower_limit_celsius: lower_limit_celsius, lower_limit_fahrenheit: lower_limit_fahrenheit, upper_limit_celsius: upper_limit_celsius, upper_limit_fahrenheit: upper_limit_fahrenheit}.compact) @@ -197,7 +197,7 @@ def set_temperature_threshold(device_id:, lower_limit_celsius: nil, lower_limit_ # @param hvac_mode_setting [String, nil] Desired [HVAC mode](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/hvac-mode) setting, such as `heat`, `cool`, `heat_cool`, or `off`. # @param manual_override_allowed [Boolean, nil] Indicates whether a person at the thermostat can change the thermostat's settings. See [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). # @deprecated manual_override_allowed: Use 'thermostat_schedule.is_override_allowed' - # @param name [String, nil] User-friendly name to identify the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). + # @param name [String, Seam::Null, nil] User-friendly name to identify the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). # @return [nil] OK def update_climate_preset(climate_preset_key:, device_id:, climate_preset_mode: nil, cooling_set_point_celsius: nil, cooling_set_point_fahrenheit: nil, ecobee_metadata: nil, fan_mode_setting: nil, heating_set_point_celsius: nil, heating_set_point_fahrenheit: nil, hvac_mode_setting: nil, manual_override_allowed: nil, name: nil) @client.patch("/thermostats/update_climate_preset", {climate_preset_key: climate_preset_key, device_id: device_id, climate_preset_mode: climate_preset_mode, cooling_set_point_celsius: cooling_set_point_celsius, cooling_set_point_fahrenheit: cooling_set_point_fahrenheit, ecobee_metadata: ecobee_metadata, fan_mode_setting: fan_mode_setting, heating_set_point_celsius: heating_set_point_celsius, heating_set_point_fahrenheit: heating_set_point_fahrenheit, hvac_mode_setting: hvac_mode_setting, manual_override_allowed: manual_override_allowed, name: name}.compact) @@ -207,13 +207,13 @@ def update_climate_preset(climate_preset_key:, device_id:, climate_preset_mode: # Updates the thermostat weekly program for a thermostat device. To configure a weekly program, specify the ID of the daily program that you want to use for each day of the week. When you update a weekly program, the set of programs that you specify overwrites any previous weekly program for the thermostat. # @param device_id [String] ID of the thermostat device for which you want to update the weekly program. - # @param friday_program_id [String, nil] ID of the thermostat daily program to run on Fridays. - # @param monday_program_id [String, nil] ID of the thermostat daily program to run on Mondays. - # @param saturday_program_id [String, nil] ID of the thermostat daily program to run on Saturdays. - # @param sunday_program_id [String, nil] ID of the thermostat daily program to run on Sundays. - # @param thursday_program_id [String, nil] ID of the thermostat daily program to run on Thursdays. - # @param tuesday_program_id [String, nil] ID of the thermostat daily program to run on Tuesdays. - # @param wednesday_program_id [String, nil] ID of the thermostat daily program to run on Wednesdays. + # @param friday_program_id [String, Seam::Null, nil] ID of the thermostat daily program to run on Fridays. + # @param monday_program_id [String, Seam::Null, nil] ID of the thermostat daily program to run on Mondays. + # @param saturday_program_id [String, Seam::Null, nil] ID of the thermostat daily program to run on Saturdays. + # @param sunday_program_id [String, Seam::Null, nil] ID of the thermostat daily program to run on Sundays. + # @param thursday_program_id [String, Seam::Null, nil] ID of the thermostat daily program to run on Thursdays. + # @param tuesday_program_id [String, Seam::Null, nil] ID of the thermostat daily program to run on Tuesdays. + # @param wednesday_program_id [String, Seam::Null, nil] ID of the thermostat daily program to run on Wednesdays. # @return [Seam::Resources::ActionAttempt] OK def update_weekly_program(device_id:, friday_program_id: nil, monday_program_id: nil, saturday_program_id: nil, sunday_program_id: nil, thursday_program_id: nil, tuesday_program_id: nil, wednesday_program_id: nil, wait_for_action_attempt: nil) res = @client.post("/thermostats/update_weekly_program", {device_id: device_id, friday_program_id: friday_program_id, monday_program_id: monday_program_id, saturday_program_id: saturday_program_id, sunday_program_id: sunday_program_id, thursday_program_id: thursday_program_id, tuesday_program_id: tuesday_program_id, wednesday_program_id: wednesday_program_id}.compact) diff --git a/lib/seam/routes/thermostats_schedules.rb b/lib/seam/routes/thermostats_schedules.rb index f808b67..d065da5 100644 --- a/lib/seam/routes/thermostats_schedules.rb +++ b/lib/seam/routes/thermostats_schedules.rb @@ -14,7 +14,7 @@ def initialize(client:, defaults:) # @param ends_at [String] Date and time at which the new thermostat schedule ends, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. # @param starts_at [String] Date and time at which the new thermostat schedule starts, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. # @param is_override_allowed [Boolean, nil] Indicates whether a person at the thermostat or using the API can change the thermostat's settings while the new schedule is active. See also [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). - # @param max_override_period_minutes [Integer, nil] Number of minutes for which a person at the thermostat or using the API can change the thermostat's settings after the activation of the scheduled climate preset. See also [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). + # @param max_override_period_minutes [Integer, Seam::Null, nil] Number of minutes for which a person at the thermostat or using the API can change the thermostat's settings after the activation of the scheduled climate preset. See also [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). # @param name [String, nil] Name of the thermostat schedule. # @return [Seam::Resources::ThermostatSchedule] OK def create(climate_preset_key:, device_id:, ends_at:, starts_at:, is_override_allowed: nil, max_override_period_minutes: nil, name: nil) @@ -56,7 +56,7 @@ def list(device_id:, user_identifier_key: nil) # @param climate_preset_key [String, nil] Key of the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets) to use for the thermostat schedule. # @param ends_at [String, nil] Date and time at which the thermostat schedule ends, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. # @param is_override_allowed [Boolean, nil] Indicates whether a person at the thermostat or using the API can change the thermostat's settings while the schedule is active. See also [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). - # @param max_override_period_minutes [Integer, nil] Number of minutes for which a person at the thermostat or using the API can change the thermostat's settings after the activation of the scheduled climate preset. See also [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). + # @param max_override_period_minutes [Integer, Seam::Null, nil] Number of minutes for which a person at the thermostat or using the API can change the thermostat's settings after the activation of the scheduled climate preset. See also [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). # @param name [String, nil] Name of the thermostat schedule. # @param starts_at [String, nil] Date and time at which the thermostat schedule starts, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. # @return [nil] OK diff --git a/lib/seam/routes/user_identities.rb b/lib/seam/routes/user_identities.rb index e7d6f04..dc96178 100644 --- a/lib/seam/routes/user_identities.rb +++ b/lib/seam/routes/user_identities.rb @@ -29,10 +29,10 @@ def add_acs_user(acs_user_id:, user_identity_id: nil, user_identity_key: nil) # Creates a new [user identity](https://docs.seam.co/capability-guides/mobile-access/managing-mobile-app-user-accounts-with-user-identities#what-is-a-user-identity). # @param acs_system_ids [Array, nil] List of access system IDs to associate with the new user identity through access system users. If there's no user with the same email address or phone number in the specified access systems, a new access system user is created. If there is an existing user with the same email or phone number in the specified access systems, the user is linked to the user identity. - # @param email_address [String, nil] Unique email address for the new user identity. - # @param full_name [String, nil] Full name of the user associated with the new user identity. - # @param phone_number [String, nil] Unique phone number for the new user identity in E.164 format (for example, +15555550100). - # @param user_identity_key [String, nil] Unique key for the new user identity. + # @param email_address [String, Seam::Null, nil] Unique email address for the new user identity. + # @param full_name [String, Seam::Null, nil] Full name of the user associated with the new user identity. + # @param phone_number [String, Seam::Null, nil] Unique phone number for the new user identity in E.164 format (for example, +15555550100). + # @param user_identity_key [String, Seam::Null, nil] Unique key for the new user identity. # @return [Seam::Resources::UserIdentity] OK def create(acs_system_ids: nil, email_address: nil, full_name: nil, phone_number: nil, user_identity_key: nil) res = @client.post("/user_identities/create", {acs_system_ids: acs_system_ids, email_address: email_address, full_name: full_name, phone_number: phone_number, user_identity_key: user_identity_key}.compact) @@ -88,7 +88,7 @@ def grant_access_to_device(device_id:, user_identity_id:) # @param created_before [Time, nil] Timestamp by which to limit returned user identities. Returns user identities created before this timestamp. # @param credential_manager_acs_system_id [String, nil] `acs_system_id` of the credential manager by which you want to filter the list of user identities. # @param limit [Integer, nil] Maximum number of records to return per page. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned user identities to include all records that satisfy a partial match using `full_name`, `phone_number`, `email_address` or `user_identity_id`. # @param user_identity_ids [Array, nil] Array of user identity IDs by which to filter the list of user identities. # @return [Seam::Resources::UserIdentity] OK @@ -156,10 +156,10 @@ def revoke_access_to_device(device_id:, user_identity_id:) # Updates a specified [user identity](https://docs.seam.co/capability-guides/mobile-access/managing-mobile-app-user-accounts-with-user-identities#what-is-a-user-identity). # @param user_identity_id [String] ID of the user identity that you want to update. - # @param email_address [String, nil] Unique email address for the user identity. - # @param full_name [String, nil] Full name of the user associated with the user identity. - # @param phone_number [String, nil] Unique phone number for the user identity. - # @param user_identity_key [String, nil] Unique key for the user identity. + # @param email_address [String, Seam::Null, nil] Unique email address for the user identity. + # @param full_name [String, Seam::Null, nil] Full name of the user associated with the user identity. + # @param phone_number [String, Seam::Null, nil] Unique phone number for the user identity. + # @param user_identity_key [String, Seam::Null, nil] Unique key for the user identity. # @return [nil] OK def update(user_identity_id:, email_address: nil, full_name: nil, phone_number: nil, user_identity_key: nil) @client.patch("/user_identities/update", {user_identity_id: user_identity_id, email_address: email_address, full_name: full_name, phone_number: phone_number, user_identity_key: user_identity_key}.compact) diff --git a/lib/seam/routes/user_identities_unmanaged.rb b/lib/seam/routes/user_identities_unmanaged.rb index 30f972c..910db0f 100644 --- a/lib/seam/routes/user_identities_unmanaged.rb +++ b/lib/seam/routes/user_identities_unmanaged.rb @@ -20,7 +20,7 @@ def get(user_identity_id:) # Returns a list of all unmanaged [user identities](https://docs.seam.co/capability-guides/mobile-access/managing-mobile-app-user-accounts-with-user-identities#what-is-a-user-identity) (where is_managed = false). # @param created_before [Time, nil] Timestamp by which to limit returned unmanaged user identities. Returns user identities created before this timestamp. # @param limit [Integer, nil] Maximum number of records to return per page. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned unmanaged user identities to include all records that satisfy a partial match using `full_name`, `phone_number`, `email_address`, `user_identity_id` or `acs_system_id`. # @return [Seam::Resources::UnmanagedUserIdentity] OK def list(created_before: nil, limit: nil, page_cursor: nil, search: nil) diff --git a/lib/seam/routes/workspaces.rb b/lib/seam/routes/workspaces.rb index bf9badd..40505d2 100644 --- a/lib/seam/routes/workspaces.rb +++ b/lib/seam/routes/workspaces.rb @@ -13,7 +13,7 @@ def initialize(client:, defaults:) # Creates a new [workspace](https://docs.seam.co/core-concepts/workspaces). # @param name [String] Name of the new workspace. # @param company_name [String, nil] Company name for the new workspace. - # @param connect_partner_name [String, nil] Connect partner name for the new workspace. + # @param connect_partner_name [String, Seam::Null, nil] Connect partner name for the new workspace. # @deprecated connect_partner_name: Use `company_name` instead. # @param connect_webview_customization [Hash, nil] [Connect Webview](https://docs.seam.co/core-concepts/connect-webviews) customizations for the new workspace. See also [Customize the Look and Feel of Your Connect Webviews](https://docs.seam.co/core-concepts/connect-webviews/customizing-connect-webviews#customize-the-look-and-feel-of-your-connect-webviews). # @param is_sandbox [Boolean, nil] Indicates whether the new workspace is a [sandbox workspace](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces). diff --git a/lib/seam/strict_url_search_params_serializer.rb b/lib/seam/strict_url_search_params_serializer.rb new file mode 100644 index 0000000..2110cf6 --- /dev/null +++ b/lib/seam/strict_url_search_params_serializer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require_relative "url_search_params_serializer" + +# Strict serialization, used by the SDK itself: _strict=true is added to any +# non-empty query so the Seam API uses strict, schema-aware parsing. +module Seam + # (see UrlSearchParamsSerializer.serialize_url_search_params) + def self.serialize_url_search_params(params) + UrlSearchParamsSerializer.serialize_url_search_params(params, strict: true) + end + + # (see UrlSearchParamsSerializer.update_url_search_params) + def self.update_url_search_params(search_params, params) + UrlSearchParamsSerializer.update_url_search_params(search_params, params, strict: true) + end +end diff --git a/lib/seam/url_search_params.rb b/lib/seam/url_search_params.rb new file mode 100644 index 0000000..a010b90 --- /dev/null +++ b/lib/seam/url_search_params.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "uri" + +module Seam + # A mutable, ordered list of name/value string pairs modeling the parts of + # the WHATWG URLSearchParams interface that the Seam URL search params + # serializer needs. + class UrlSearchParams + include Enumerable + + # @param init [String, Hash, Enumerable, nil] + def initialize(init = nil) + @pairs = [] + return if init.nil? + + case init + when String + query = init.delete_prefix("?") + URI.decode_www_form(query).each { |name, value| append(name, value) } unless query.empty? + when Hash + init.each { |name, value| append(name, value) } + else + init.each { |name, value| append(name, value) } + end + end + + def append(name, value) + @pairs << [name.to_s, value.to_s] + nil + end + + def set(name, value) + name = name.to_s + replaced = false + @pairs = @pairs.filter_map do |pair| + next pair unless pair.first == name + next nil if replaced + + replaced = true + [name, value.to_s] + end + append(name, value) unless replaced + nil + end + + def get(name) + name = name.to_s + @pairs.each { |pair_name, value| return value if pair_name == name } + nil + end + + def get_all(name) + name = name.to_s + @pairs.filter_map { |pair_name, value| value if pair_name == name } + end + + def has?(name) + name = name.to_s + @pairs.any? { |pair_name, _| pair_name == name } + end + + def delete(name) + name = name.to_s + @pairs.reject! { |pair_name, _| pair_name == name } + nil + end + + def sort! + @pairs = @pairs.each_with_index.sort_by do |(name, _), index| + [name.encode(Encoding::UTF_16BE).b, index] + end.map(&:first) + nil + end + + def each(&block) + return @pairs.each unless block + + @pairs.each(&block) + self + end + + def size + @pairs.size + end + alias_method :length, :size + + def empty? + @pairs.empty? + end + + def to_s + @pairs.map do |name, value| + "#{self.class.encode_component(name)}=#{self.class.encode_component(value)}" + end.join("&") + end + + def self.encode_component(string) + URI.encode_www_form_component(string.encode(Encoding::UTF_8)) + end + end +end diff --git a/lib/seam/url_search_params_serializer.rb b/lib/seam/url_search_params_serializer.rb new file mode 100644 index 0000000..30c5d93 --- /dev/null +++ b/lib/seam/url_search_params_serializer.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +require "date" + +require_relative "null" +require_relative "url_search_params" + +module Seam + class UnserializableParamError < StandardError + attr_reader :param_name + + def initialize(param_name, reason) + @param_name = param_name + super("Could not serialize parameter: '#{param_name}' #{reason}") + end + end + + def self.replace_null(value) + case value + when Seam::Null then nil + when Hash then value.transform_values { |v| replace_null(v) } + when Array then value.map { |v| replace_null(v) } + else value + end + end + + # Serializes parameters to a URL query string following the + # @seamapi/url-search-params-serializer standard: + # https://github.com/seamapi/url-search-params-serializer + module UrlSearchParamsSerializer + # @param params [Hash] + # @param strict [Boolean] Whether to add +_strict=true+ to a non-empty + # query string + # @return [String] The query string, without a leading +?+ + # @raise [UnserializableParamError] + def self.serialize_url_search_params(params, strict: false) + search_params = UrlSearchParams.new + update_url_search_params(search_params, params, strict: strict) + search_params.to_s + end + + # Serializes parameters into an existing {UrlSearchParams} collection, + # preserving pairs it does not overwrite. + # + # @param search_params [UrlSearchParams] + # @param params [Hash] + # @param strict [Boolean] Whether to add +_strict=true+ when the + # resulting collection is non-empty + # @return [nil] + # @raise [UnserializableParamError] + def self.update_url_search_params(search_params, params, strict: false) + nested_update(search_params, params, []) + search_params.sort! + + if strict && !search_params.empty? + search_params.delete("_strict") + search_params.append("_strict", "true") + end + + nil + end + + def self.nested_update(search_params, params, path) + params.each do |key, value| + unless key.is_a?(String) || key.is_a?(Symbol) + raise UnserializableParamError.new( + key.inspect, + "has a name that is not a string which is unsupported" + ) + end + key = key.to_s + + if key.include?(".") + raise UnserializableParamError.new( + key, + 'contains one or more dots "." in its name which is unsupported' + ) + end + + current_path = [*path, key] + + if value.is_a?(Hash) + nested_update(search_params, value, current_path) + next + end + + name = current_path.join(".") + + next if value.nil? + + value = value.to_s if value.is_a?(Symbol) + + next if value.is_a?(String) && value.empty? + + if value.is_a?(Array) + serialize_array(search_params, name, value) + next + end + + search_params.set(name, serialize_value(name, value)) + end + end + + def self.serialize_array(search_params, name, values) + # The parser reads a single pair with an empty value as an empty array. + if values.empty? + search_params.set(name, "") + return + end + + values = values.map { |value| value.is_a?(Symbol) ? value.to_s : value } + + if values.length == 1 && values.first == "" + raise UnserializableParamError.new( + name, + "is a single element array containing the empty string which is unsupported" + ) + end + + if values.any? { |value| value == "" } + raise UnserializableParamError.new( + name, + "is an array containing the empty string which is unsupported" + ) + end + + if values.any? { |value| value.nil? || value.is_a?(Seam::Null) } + raise UnserializableParamError.new( + name, + "is an array containing null or undefined values which is unsupported" + ) + end + + values.each { |value| search_params.append(name, serialize_value(name, value)) } + end + + def self.serialize_value(name, value) + case value + when Seam::Null then "" + when String then value + when true, false then value.to_s + when Integer then value.to_s + when Float then serialize_float(name, value) + when Time then serialize_time(value) + when DateTime then serialize_time(value.to_time) + when Date then serialize_time(Time.utc(value.year, value.month, value.day)) + else + raise UnserializableParamError.new(name, "is a #{value.class}") + end + end + + # Formats a float exactly like ECMAScript Number::toString. + def self.serialize_float(name, value) + raise UnserializableParamError.new(name, "is NaN") if value.nan? + if value.infinite? + raise UnserializableParamError.new(name, "is #{value.positive? ? "Infinity" : "-Infinity"}") + end + return "0" if value.zero? + + digits, point = shortest_decimal(value.abs) + count = digits.length + + formatted = if point.between?(count, 21) + digits + "0" * (point - count) + elsif point.positive? && point <= 21 + "#{digits[0, point]}.#{digits[point..]}" + elsif point > -6 && point <= 0 + "0.#{"0" * -point}#{digits}" + else + mantissa = (count == 1) ? digits : "#{digits[0]}.#{digits[1..]}" + exponent = point - 1 + "#{mantissa}e#{(exponent >= 0) ? "+" : "-"}#{exponent.abs}" + end + + value.negative? ? "-#{formatted}" : formatted + end + + # Returns the shortest round-tripping decimal digits of a positive float + # and the position of the decimal point relative to the first digit. + def self.shortest_decimal(value) + repr = value.to_s + + if repr.include?("e") + mantissa, exponent = repr.split("e") + integer_part, fraction_part = mantissa.split(".") + digits = integer_part + (fraction_part || "") + point = integer_part.length + exponent.to_i + else + integer_part, fraction_part = repr.split(".") + digits = integer_part + (fraction_part || "") + point = integer_part.length + end + + leading_zeros = digits[/\A0*/].length + digits = digits[leading_zeros..] + point -= leading_zeros + + [digits.sub(/0+\z/, ""), point] + end + + # Formats a time exactly like JavaScript's Date#toISOString. + def self.serialize_time(time) + utc = time.getutc + year = if utc.year.between?(0, 9999) + format("%04d", utc.year) + elsif utc.year > 9999 + format("+%06d", utc.year) + else + format("-%06d", -utc.year) + end + format( + "%s-%02d-%02dT%02d:%02d:%02d.%03dZ", + year, utc.month, utc.day, utc.hour, utc.min, utc.sec, utc.nsec / 1_000_000 + ) + end + end +end diff --git a/spec/null_spec.rb b/spec/null_spec.rb new file mode 100644 index 0000000..2211ecc --- /dev/null +++ b/spec/null_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +RSpec.describe Seam::NULL do + it "is the single instance of Seam::Null" do + expect(described_class).to be_a(Seam::Null) + expect(described_class).to equal(Seam::Null.instance) + end + + it "cannot be constructed a second time" do + expect { Seam::Null.new }.to raise_error(NoMethodError) + end + + it "is detected by type rather than identity" do + expect(described_class.is_a?(Seam::Null)).to be true + end + + it "reads as its own name in error messages and debuggers" do + expect(described_class.to_s).to eq("NULL") + expect(described_class.inspect).to eq("NULL") + end + + it "is not nil, so Hash#compact keeps it" do + expect({name: described_class}.compact).to eq({name: described_class}) + end +end diff --git a/spec/seam_client/headers_spec.rb b/spec/seam_client/headers_spec.rb index 48bf237..3781fc0 100644 --- a/spec/seam_client/headers_spec.rb +++ b/spec/seam_client/headers_spec.rb @@ -6,7 +6,7 @@ let(:device_id) { "device_id_1234" } it "sends the SDK and auth headers" do - stub = stub_request(:get, "#{Seam::DEFAULT_ENDPOINT}/devices/get?device_id=#{device_id}") + stub = stub_request(:get, "#{Seam::DEFAULT_ENDPOINT}/devices/get?device_id=#{device_id}&_strict=true") .with( headers: { "Authorization" => "Bearer seam_some_api_key", @@ -30,7 +30,7 @@ end it "sends the workspace header with a personal access token" do - stub = stub_request(:get, "#{Seam::DEFAULT_ENDPOINT}/devices/get?device_id=#{device_id}") + stub = stub_request(:get, "#{Seam::DEFAULT_ENDPOINT}/devices/get?device_id=#{device_id}&_strict=true") .with( headers: { "Authorization" => "Bearer seam_at_token", diff --git a/spec/seam_client/url_search_params_spec.rb b/spec/seam_client/url_search_params_spec.rb new file mode 100644 index 0000000..52138b8 --- /dev/null +++ b/spec/seam_client/url_search_params_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +RSpec.describe Seam::Http::Request, recorder: true do + describe "query params" do + it "serializes arrays by repeating the name, preserving order" do + seam.client.get("/devices/list", {device_ids: %w[device-2 device-1]}) + + expect(recorder.requests.first.query).to eq("device_ids=device-2&device_ids=device-1&_strict=true") + end + + it "serializes an empty array as a single pair with an empty value" do + seam.client.get("/devices/list", {device_ids: []}) + + expect(recorder.requests.first.query).to eq("device_ids=&_strict=true") + end + + it "serializes nested objects by joining keys with dots" do + seam.client.get("/devices/list", {custom_metadata_has: {internal_account_id: "user-1"}}) + + expect(recorder.requests.first.query).to eq("custom_metadata_has.internal_account_id=user-1&_strict=true") + end + + it "encodes with the WHATWG form serializer, escaping ~ and passing * through" do + seam.client.get("/devices/list", {search: "a *~ b"}) + + expect(recorder.requests.first.query).to eq("search=a+*%7E+b&_strict=true") + end + + it "sorts pairs by name" do + seam.client.get("/devices/list", {limit: 5, device_type: "august_lock", search: "x"}) + + expect(recorder.requests.first.query).to eq("device_type=august_lock&limit=5&search=x&_strict=true") + end + + it "omits nil params entirely" do + seam.client.get("/devices/list", {search: nil, limit: 1}) + + expect(recorder.requests.first.query).to eq("limit=1&_strict=true") + end + + it "serializes the NULL sentinel as an empty value" do + seam.client.get("/devices/list", {search: Seam::NULL}) + + expect(recorder.requests.first.query).to eq("search=&_strict=true") + end + + it "emits no bare ? when nothing serializes" do + seam.client.get("/devices/list", {search: nil}) + seam.client.get("/devices/list") + + expect(recorder.requests.map(&:target)).to eq(["/devices/list", "/devices/list"]) + end + + it "emits the serialized query verbatim through base URL resolution" do + seam.client.get("/devices/list", {device_ids: [], search: "a *~ b"}) + + expect(recorder.requests.first.target).to eq("/devices/list?device_ids=&search=a+*%7E+b&_strict=true") + end + + it "passes a query string already built by the caller through verbatim" do + seam.client.get("/devices/list?already=built&ids=") + + expect(recorder.requests.first.query).to eq("already=built&ids=") + end + + it "merges params into a query string already built by the caller" do + seam.client.get("/devices/list?built=1", {added: "a b"}) + + expect(recorder.requests.first.query).to eq("added=a+b&built=1&_strict=true") + end + + it "raises the typed error before any request is sent" do + expect { + seam.client.get("/devices/list", {device_ids: ["a", ""]}) + }.to raise_error(Seam::UnserializableParamError) do |error| + expect(error.param_name).to eq("device_ids") + end + + expect(recorder.requests).to be_empty + end + + it "serializes query params on DELETE requests" do + seam.client.delete("/acs/access_groups/delete", {acs_access_group_id: "group-1"}) + + request = recorder.requests.first + expect(request.method).to eq("DELETE") + expect(request.query).to eq("acs_access_group_id=group-1&_strict=true") + end + end + + describe "request bodies" do + it "sends the NULL sentinel as JSON null on POST" do + seam.client.post("/thermostats/update", {device_id: "device-1", default_climate_setting: Seam::NULL}) + + request = recorder.requests.first + expect(request.method).to eq("POST") + expect(JSON.parse(request.body)).to eq( + "device_id" => "device-1", + "default_climate_setting" => nil + ) + end + + it "replaces the sentinel in nested hashes and arrays" do + seam.client.post("/x", {a: {b: Seam::NULL}, c: [Seam::NULL, 1]}) + + expect(JSON.parse(recorder.requests.first.body)).to eq( + "a" => {"b" => nil}, + "c" => [nil, 1] + ) + end + + it "does not mutate the caller's payload" do + payload = {device_id: "device-1", name: Seam::NULL} + seam.client.post("/x", payload) + + expect(payload[:name]).to equal(Seam::NULL) + end + + it "sends the sentinel as JSON null on PUT and PATCH" do + seam.client.put("/x", {name: Seam::NULL}) + seam.client.patch("/x", {name: Seam::NULL}) + + expect(recorder.requests.map(&:method)).to eq(%w[PUT PATCH]) + recorder.requests.each do |request| + expect(JSON.parse(request.body)).to eq("name" => nil) + end + end + end + + describe "generated routes" do + it "serializes a generated GET route end to end" do + recorder.respond_with({device: {device_id: "device-1"}}.to_json) + + device = seam.locks.get(device_id: "device-1") + + request = recorder.requests.first + expect(request.method).to eq("GET") + expect(request.target).to eq("/locks/get?device_id=device-1&_strict=true") + expect(device.device_id).to eq("device-1") + end + + it "omits absent optional params from a generated route" do + recorder.respond_with({device: {device_id: "device-1"}}.to_json) + + seam.locks.get(device_id: "device-1", name: nil) + + expect(recorder.requests.first.target).to eq("/locks/get?device_id=device-1&_strict=true") + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 294aad5..af6fe0f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -17,6 +17,7 @@ require "webmock/rspec" require "support/fake_seam_connect" +require "support/raw_request_recorder" SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ SimpleCov::Formatter::HTMLFormatter, @@ -38,4 +39,5 @@ end config.include_context "with fake seam connect", fake: true + config.include_context "with raw request recorder", recorder: true end diff --git a/spec/support/raw_request_recorder.rb b/spec/support/raw_request_recorder.rb new file mode 100644 index 0000000..c5a3cc2 --- /dev/null +++ b/spec/support/raw_request_recorder.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "socket" + +# A local HTTP server that records the exact bytes of each request line and +# body. +class RawRequestRecorder + RecordedRequest = Struct.new(:method, :target, :body) do + def query + _, query = target.split("?", 2) + query + end + + def path + target.split("?", 2).first + end + end + + attr_reader :endpoint, :requests + + def initialize + @server = TCPServer.new("127.0.0.1", 0) + @endpoint = "http://127.0.0.1:#{@server.addr[1]}" + @requests = [] + @response_body = "{}" + @thread = Thread.new { serve } + @thread.abort_on_exception = true + end + + def respond_with(body) + @response_body = body + end + + def stop + @thread.kill + @server.close + end + + private + + def serve + loop do + socket = @server.accept + begin + handle(socket) + ensure + socket.close + end + end + end + + def handle(socket) + request_line = socket.gets + return if request_line.nil? + + method, target, = request_line.split(" ") + + content_length = 0 + while (line = socket.gets) + break if line == "\r\n" + + name, value = line.split(":", 2) + content_length = value.to_i if name.casecmp?("content-length") + end + + body = content_length.positive? ? socket.read(content_length) : nil + @requests << RecordedRequest.new(method, target, body) + + socket.write( + "HTTP/1.1 200 OK\r\n" \ + "Content-Type: application/json\r\n" \ + "Content-Length: #{@response_body.bytesize}\r\n" \ + "Connection: close\r\n" \ + "\r\n" \ + "#{@response_body}" + ) + end +end + +RSpec.shared_context "with raw request recorder" do + let(:recorder) { RawRequestRecorder.new } + let(:seam) { Seam.new(api_key: "seam_some_api_key", endpoint: recorder.endpoint) } + + after { recorder.stop } +end diff --git a/spec/url_search_params_serializer_spec.rb b/spec/url_search_params_serializer_spec.rb new file mode 100644 index 0000000..b3b6582 --- /dev/null +++ b/spec/url_search_params_serializer_spec.rb @@ -0,0 +1,259 @@ +# frozen_string_literal: true + +RSpec.describe "Seam.serialize_url_search_params" do + def serialize(params) + Seam::UrlSearchParamsSerializer.serialize_url_search_params(params) + end + + it "serializes scalars and sorts pairs by name" do + expect(serialize({foo: "d", bar: 2})).to eq("bar=2&foo=d") + end + + it "accepts string and symbol keys" do + expect(serialize({"a" => 1, :b => 2})).to eq("a=1&b=2") + end + + it "serializes symbol values as strings" do + expect(serialize({mode: :heating})).to eq("mode=heating") + end + + describe "absent, null, and empty values" do + it "omits nil params entirely" do + expect(serialize({a: nil, b: "x"})).to eq("b=x") + end + + it "serializes the NULL sentinel as an empty value" do + expect(serialize({a: Seam::NULL, b: "x"})).to eq("a=&b=x") + end + + it "omits empty string params entirely" do + expect(serialize({a: "", b: "x"})).to eq("b=x") + end + + it "serializes to an empty string when nothing is serializable" do + expect(serialize({})).to eq("") + expect(serialize({a: nil, b: ""})).to eq("") + end + end + + describe "nested hashes" do + it "joins nested keys with dots" do + expect(serialize({a: {b: {c: 1}}})).to eq("a.b.c=1") + end + + it "rejects keys containing dots" do + expect { serialize({"a.b" => 1}) }.to raise_error(Seam::UnserializableParamError) do |error| + expect(error.param_name).to eq("a.b") + expect(error.message).to eq( + "Could not serialize parameter: 'a.b' contains one or more dots \".\" in its name which is unsupported" + ) + end + end + + it "rejects nested keys containing dots" do + expect { serialize({a: {"b.c" => 1}}) }.to raise_error(Seam::UnserializableParamError) + end + + it "rejects keys that are not strings or symbols" do + expect { serialize({1 => "x"}) }.to raise_error(Seam::UnserializableParamError) + end + end + + describe "arrays" do + it "repeats the name for each element, preserving order" do + expect(serialize({ids: %w[b a]})).to eq("ids=b&ids=a") + end + + it "serializes an empty array as a single pair with an empty value" do + expect(serialize({ids: []})).to eq("ids=") + end + + it "rejects a single element array containing the empty string" do + expect { serialize({ids: [""]}) }.to raise_error( + Seam::UnserializableParamError, + "Could not serialize parameter: 'ids' is a single element array containing the empty string which is unsupported" + ) + end + + it "rejects arrays containing the empty string" do + expect { serialize({ids: ["a", ""]}) }.to raise_error( + Seam::UnserializableParamError, + "Could not serialize parameter: 'ids' is an array containing the empty string which is unsupported" + ) + end + + it "rejects arrays containing nil or NULL" do + message = "Could not serialize parameter: 'ids' is an array containing null or undefined values which is unsupported" + expect { serialize({ids: ["a", nil]}) }.to raise_error(Seam::UnserializableParamError, message) + expect { serialize({ids: ["a", Seam::NULL]}) }.to raise_error(Seam::UnserializableParamError, message) + end + end + + it "serializes booleans as true and false" do + expect(serialize({a: true, b: false})).to eq("a=true&b=false") + end + + it "encodes multi-byte and astral characters in keys and values" do + expect(serialize({"\u{1F600}" => "café"})).to eq("%F0%9F%98%80=caf%C3%A9") + end + + describe "numbers" do + it "serializes integers with full decimal digits at arbitrary precision" do + expect(serialize({n: 123456789123456789123456789})).to eq("n=123456789123456789123456789") + expect(serialize({n: -42})).to eq("n=-42") + end + + it "serializes integral floats without a trailing .0" do + expect(serialize({n: 1.0})).to eq("n=1") + expect(serialize({n: -100.0})).to eq("n=-100") + end + + it "serializes zero as 0, including negative zero" do + expect(serialize({n: 0.0})).to eq("n=0") + expect(serialize({n: -0.0})).to eq("n=0") + end + + it "switches to exponent notation at 1e21 but not 1e20" do + expect(serialize({n: 1e20})).to eq("n=100000000000000000000") + expect(serialize({n: 1e21})).to eq("n=1e%2B21") + end + + it "switches to exponent notation at 1e-7 but not 1e-6" do + expect(serialize({n: 1e-6})).to eq("n=0.000001") + expect(serialize({n: 1e-7})).to eq("n=1e-7") + end + + it "serializes the shortest round-tripping digits" do + expect(serialize({n: 0.1 + 0.2})).to eq("n=0.30000000000000004") + expect(serialize({n: 123.456})).to eq("n=123.456") + end + + it "formats exponents like ECMAScript, signed and without zero padding" do + expect(serialize({n: 1.5e22})).to eq("n=1.5e%2B22") + expect(serialize({n: 5e-324})).to eq("n=5e-324") + expect(serialize({n: 1.7976931348623157e308})).to eq("n=1.7976931348623157e%2B308") + end + + it "rejects NaN and infinities with their own messages" do + expect { serialize({n: Float::NAN}) }.to raise_error( + Seam::UnserializableParamError, "Could not serialize parameter: 'n' is NaN" + ) + expect { serialize({n: Float::INFINITY}) }.to raise_error( + Seam::UnserializableParamError, "Could not serialize parameter: 'n' is Infinity" + ) + expect { serialize({n: -Float::INFINITY}) }.to raise_error( + Seam::UnserializableParamError, "Could not serialize parameter: 'n' is -Infinity" + ) + end + end + + describe "times" do + it "serializes with exactly three fractional digits and a literal Z" do + expect(serialize({t: Time.utc(2024, 1, 2, 3, 4, 5)})).to eq("t=2024-01-02T03%3A04%3A05.000Z") + end + + it "converts to UTC first" do + time = Time.new(2024, 1, 2, 3, 4, 5, "+05:00") + expect(serialize({t: time})).to eq("t=2024-01-01T22%3A04%3A05.000Z") + end + + it "truncates sub-millisecond precision toward zero" do + time = Time.utc(2024, 1, 2, 3, 4, 5, 123_999.999) + expect(serialize({t: time})).to eq("t=2024-01-02T03%3A04%3A05.123Z") + end + + it "zero-pads the year to four digits" do + expect(serialize({t: Time.utc(999, 1, 2)})).to eq("t=0999-01-02T00%3A00%3A00.000Z") + end + + it "uses the expanded six-digit form for years outside 0000..9999, like Date#toISOString" do + expect(serialize({t: Time.utc(10_000, 1, 1)})).to eq("t=%2B010000-01-01T00%3A00%3A00.000Z") + expect(serialize({t: Time.utc(-5, 1, 1)})).to eq("t=-000005-01-01T00%3A00%3A00.000Z") + end + + it "serializes DateTime and Date values" do + expect(serialize({t: DateTime.new(2024, 1, 2, 3, 4, 5, "+00:00")})) + .to eq("t=2024-01-02T03%3A04%3A05.000Z") + expect(serialize({t: Date.new(2024, 1, 2)})).to eq("t=2024-01-02T00%3A00%3A00.000Z") + end + end + + describe "unsupported values" do + it "rejects unordered collections, which would not serialize deterministically" do + expect { serialize({s: Set.new(["a"])}) }.to raise_error( + Seam::UnserializableParamError, "Could not serialize parameter: 's' is a Set" + ) + end + + it "rejects other objects with the param name retrievable from the error" do + expect { serialize({r: Rational(1, 2)}) }.to raise_error(Seam::UnserializableParamError) do |error| + expect(error.param_name).to eq("r") + end + end + + it "raises before anything is serialized rather than sending a partial query" do + expect { serialize({a: "ok", b: Object.new}) }.to raise_error(Seam::UnserializableParamError) + end + end + + describe "strict mode, used by Seam.serialize_url_search_params" do + it "adds _strict=true to non-empty query strings" do + expect(Seam.serialize_url_search_params({})).to eq("") + expect(Seam.serialize_url_search_params({foo: "d"})).to eq("foo=d&_strict=true") + end + + it "keeps _strict last, after the sorted pairs" do + expect(Seam.serialize_url_search_params({z: 1, a: 2})).to eq("a=2&z=1&_strict=true") + end + + it "replaces a _strict param passed by the caller" do + expect(Seam.serialize_url_search_params({_strict: false})).to eq("_strict=true") + end + end + + describe "Seam.update_url_search_params" do + it "serializes into an existing collection, preserving other pairs, then sorts" do + search_params = Seam::UrlSearchParams.new("z=1&a=2") + Seam::UrlSearchParamsSerializer.update_url_search_params(search_params, {m: "x"}) + + expect(search_params.to_s).to eq("a=2&m=x&z=1") + end + + it "overwrites pairs with names it serializes" do + search_params = Seam::UrlSearchParams.new("a=old") + Seam::UrlSearchParamsSerializer.update_url_search_params(search_params, {a: "new"}) + + expect(search_params.to_s).to eq("a=new") + end + + it "adds _strict=true when the resulting collection is non-empty" do + search_params = Seam::UrlSearchParams.new("z=1") + Seam.update_url_search_params(search_params, {}) + + expect(search_params.to_s).to eq("z=1&_strict=true") + end + + it "replaces an existing _strict pair rather than repeating it" do + search_params = Seam::UrlSearchParams.new("_strict=true&a=1") + Seam.update_url_search_params(search_params, {b: "2"}) + + expect(search_params.to_s).to eq("a=1&b=2&_strict=true") + end + end + + describe "Seam.replace_null" do + it "replaces the sentinel with nil recursively, returning a copy" do + payload = {a: Seam::NULL, b: [Seam::NULL, 1], c: {d: Seam::NULL}, e: "kept"} + replaced = Seam.replace_null(payload) + + expect(replaced).to eq({a: nil, b: [nil, 1], c: {d: nil}, e: "kept"}) + expect(payload[:a]).to equal(Seam::NULL) + expect(payload[:b].first).to equal(Seam::NULL) + expect(payload[:c][:d]).to equal(Seam::NULL) + end + + it "does not descend into strings" do + expect(Seam.replace_null({a: "NULL"})).to eq({a: "NULL"}) + end + end +end diff --git a/spec/url_search_params_spec.rb b/spec/url_search_params_spec.rb new file mode 100644 index 0000000..b19b0d0 --- /dev/null +++ b/spec/url_search_params_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +RSpec.describe Seam::UrlSearchParams do + describe "#append" do + it "adds a pair, keeping existing pairs with that name" do + params = described_class.new + params.append("a", "1") + params.append("a", "2") + + expect(params.get_all("a")).to eq(%w[1 2]) + expect(params.to_s).to eq("a=1&a=2") + end + end + + describe "#set" do + it "replaces the first pair in place and deletes the rest" do + params = described_class.new([%w[a 1], %w[b 2], %w[a 3]]) + params.set("a", "9") + + expect(params.to_s).to eq("a=9&b=2") + end + + it "appends when the name is absent" do + params = described_class.new([%w[a 1]]) + params.set("b", "2") + + expect(params.to_s).to eq("a=1&b=2") + end + end + + describe "#get" do + it "returns the value of the first pair with that name" do + params = described_class.new([%w[a 1], %w[a 2]]) + + expect(params.get("a")).to eq("1") + expect(params.get("missing")).to be_nil + end + end + + describe "#has?" do + it "reports whether a pair with that name exists" do + params = described_class.new([%w[a 1]]) + + expect(params.has?("a")).to be true + expect(params.has?("b")).to be false + end + end + + describe "#delete" do + it "removes all pairs with that name" do + params = described_class.new([%w[a 1], %w[b 2], %w[a 3]]) + params.delete("a") + + expect(params.to_s).to eq("b=2") + end + end + + describe "#sort!" do + it "sorts by UTF-16 code unit, putting astral characters before U+E000..U+FFFF" do + params = described_class.new + params.append("\u{FFFF}", "1") + params.append("\u{1F600}", "2") + params.sort! + + expect(params.map(&:first)).to eq(["\u{1F600}", "\u{FFFF}"]) + end + + it "is stable, preserving the order of pairs with the same name" do + params = described_class.new([%w[b 2], %w[a x], %w[a y], %w[b 1]]) + params.sort! + + expect(params.to_s).to eq("a=x&a=y&b=2&b=1") + end + end + + describe "#to_s" do + it "gives every pair an =, including empty values" do + params = described_class.new([["a", ""], %w[b 2]]) + + expect(params.to_s).to eq("a=&b=2") + end + + it "encodes with the WHATWG form serializer: space to +, ~ escaped, * literal" do + params = described_class.new + params.append("k", "a *~ b") + + expect(params.to_s).to eq("k=a+*%7E+b") + end + + it "escapes multi-byte UTF-8 one uppercase escape per byte" do + params = described_class.new + params.append("k", "\u{1F600}") + + expect(params.to_s).to eq("k=%F0%9F%98%80") + end + end + + describe "#initialize" do + it "accepts a query string, with or without a leading ?" do + expect(described_class.new("a=1&a=2&b=").to_s).to eq("a=1&a=2&b=") + expect(described_class.new("?a=1").to_s).to eq("a=1") + expect(described_class.new("").to_s).to eq("") + end + + it "accepts a hash" do + expect(described_class.new({"a" => "1", "b" => "2"}).to_s).to eq("a=1&b=2") + end + + it "accepts a sequence of pairs" do + expect(described_class.new([%w[b 2], %w[a 1]]).to_s).to eq("b=2&a=1") + end + end + + describe ".encode_component" do + it "passes the WHATWG probe" do + expect(described_class.encode_component("a *~ b")).to eq("a+*%7E+b") + end + end + + it "is enumerable over pairs in order" do + params = described_class.new([%w[b 2], %w[a 1]]) + + expect(params.to_a).to eq([%w[b 2], %w[a 1]]) + expect(params.size).to eq(2) + expect(params).not_to be_empty + expect(described_class.new).to be_empty + end +end