diff --git a/README.md b/README.md index 5266ce9..2259b6c 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,21 @@ When the `wait_for_action_attempt` option is enabled, the SDK: - Raises a `Seam::ActionAttemptTimeoutError` if the action attempt is still pending when the `timeout` is reached. - Both errors expose an `action_attempt` property. +The `error` and `result` values are only present for their matching status: +`error` is `nil` unless the `status` is `"error"`, +and `result` is `nil` unless the `status` is `"success"`. + +```ruby +action_attempt = seam.locks.unlock_door( + device_id: device_id, + wait_for_action_attempt: false +) + +action_attempt.status # => "pending" +action_attempt.error # => nil +action_attempt.result # => nil +``` + If you already have an action attempt ID and want to wait for it to resolve, simply use: diff --git a/codegen/layouts/partials/resource-class.hbs b/codegen/layouts/partials/resource-class.hbs index d489947..89de9aa 100644 --- a/codegen/layouts/partials/resource-class.hbs +++ b/codegen/layouts/partials/resource-class.hbs @@ -12,21 +12,27 @@ {{#if description}} {{{rubyDoc description ../docIndent}}} {{/if}} -{{../bodyIndent}}# @return [{{rubyResourceType this}}]{{{rubyEnumValuesDoc this ../docIndent}}} +{{../bodyIndent}}# @return [{{rubyResourceType this}}]{{{rubyEnumValuesDoc this ../docIndent}}}{{{rubyActionAttemptStatusesDoc this ../docIndent}}} {{../bodyIndent}}resource_accessor :{{name}}, {{className}} +{{#if actionAttemptStatuses}} +{{../bodyIndent}}available_only_for_statuses :{{name}}, {{rubyStringArray actionAttemptStatuses}} +{{/if}} {{/each}} {{#each resourceListAccessors}} {{#if description}} {{{rubyDoc description ../docIndent}}} {{/if}} -{{../bodyIndent}}# @return [{{rubyResourceType this}}]{{{rubyEnumValuesDoc this ../docIndent}}} +{{../bodyIndent}}# @return [{{rubyResourceType this}}]{{{rubyEnumValuesDoc this ../docIndent}}}{{{rubyActionAttemptStatusesDoc this ../docIndent}}} {{../bodyIndent}}resource_list_accessor :{{name}}, {{className}} +{{#if actionAttemptStatuses}} +{{../bodyIndent}}available_only_for_statuses :{{name}}, {{rubyStringArray actionAttemptStatuses}} +{{/if}} {{/each}} {{#each accessors}} {{#if description}} {{{rubyDoc description ../docIndent}}} {{/if}} -{{../bodyIndent}}# @return [{{rubyPropertyType this}}]{{{rubyEnumValuesDoc this ../docIndent}}} +{{../bodyIndent}}# @return [{{rubyPropertyType this}}]{{{rubyEnumValuesDoc this ../docIndent}}}{{{rubyActionAttemptStatusesDoc this ../docIndent}}} {{#if isDeprecated}} {{{rubyDeprecatedDoc this ../docIndent}}} {{/if}} @@ -35,16 +41,22 @@ {{else}} {{../bodyIndent}}attr_accessor :{{accessorName}} {{/if}} +{{#if actionAttemptStatuses}} +{{../bodyIndent}}available_only_for_statuses :{{accessorName}}, {{rubyStringArray actionAttemptStatuses}} +{{/if}} {{/each}} {{#each dateAccessors}} {{#if description}} {{{rubyDoc description ../docIndent}}} {{/if}} -{{../bodyIndent}}# @return [{{rubyPropertyType this}}]{{{rubyEnumValuesDoc this ../docIndent}}} +{{../bodyIndent}}# @return [{{rubyPropertyType this}}]{{{rubyEnumValuesDoc this ../docIndent}}}{{{rubyActionAttemptStatusesDoc this ../docIndent}}} {{#if isDeprecated}} {{{rubyDeprecatedDoc this ../docIndent}}} {{/if}} {{../bodyIndent}}date_accessor :{{accessorName}} +{{#if actionAttemptStatuses}} +{{../bodyIndent}}available_only_for_statuses :{{accessorName}}, {{rubyStringArray actionAttemptStatuses}} +{{/if}} {{/each}} {{#if discriminator}} diff --git a/codegen/layouts/resource.hbs b/codegen/layouts/resource.hbs index 3a4041e..338be80 100644 --- a/codegen/layouts/resource.hbs +++ b/codegen/layouts/resource.hbs @@ -19,21 +19,27 @@ module Seam {{#if description}} {{{rubyDoc description 6}}} {{/if}} - # @return [{{rubyResourceType this}}]{{{rubyEnumValuesDoc this 6}}} + # @return [{{rubyResourceType this}}]{{{rubyEnumValuesDoc this 6}}}{{{rubyActionAttemptStatusesDoc this 6}}} resource_accessor :{{name}}, {{className}} +{{#if actionAttemptStatuses}} + available_only_for_statuses :{{name}}, {{rubyStringArray actionAttemptStatuses}} +{{/if}} {{/each}} {{#each resourceListAccessors}} {{#if description}} {{{rubyDoc description 6}}} {{/if}} - # @return [{{rubyResourceType this}}]{{{rubyEnumValuesDoc this 6}}} + # @return [{{rubyResourceType this}}]{{{rubyEnumValuesDoc this 6}}}{{{rubyActionAttemptStatusesDoc this 6}}} resource_list_accessor :{{name}}, {{className}} +{{#if actionAttemptStatuses}} + available_only_for_statuses :{{name}}, {{rubyStringArray actionAttemptStatuses}} +{{/if}} {{/each}} {{#each accessors}} {{#if description}} {{{rubyDoc description 6}}} {{/if}} - # @return [{{rubyPropertyType this}}]{{{rubyEnumValuesDoc this 6}}} + # @return [{{rubyPropertyType this}}]{{{rubyEnumValuesDoc this 6}}}{{{rubyActionAttemptStatusesDoc this 6}}} {{#if isDeprecated}} {{{rubyDeprecatedDoc this 6}}} {{/if}} @@ -42,17 +48,23 @@ module Seam {{else}} attr_accessor :{{accessorName}} {{/if}} +{{#if actionAttemptStatuses}} + available_only_for_statuses :{{accessorName}}, {{rubyStringArray actionAttemptStatuses}} +{{/if}} {{/each}} {{#each dateAccessors}} {{#if description}} {{{rubyDoc description 6}}} {{/if}} - # @return [{{rubyPropertyType this}}]{{{rubyEnumValuesDoc this 6}}} + # @return [{{rubyPropertyType this}}]{{{rubyEnumValuesDoc this 6}}}{{{rubyActionAttemptStatusesDoc this 6}}} {{#if isDeprecated}} {{{rubyDeprecatedDoc this 6}}} {{/if}} date_accessor :{{accessorName}} +{{#if actionAttemptStatuses}} + available_only_for_statuses :{{accessorName}}, {{rubyStringArray actionAttemptStatuses}} +{{/if}} {{/each}} {{#if discriminator}} diff --git a/codegen/lib/handlebars-helpers.ts b/codegen/lib/handlebars-helpers.ts index 2607712..c794c5a 100644 --- a/codegen/lib/handlebars-helpers.ts +++ b/codegen/lib/handlebars-helpers.ts @@ -49,6 +49,31 @@ export const rubyEnumValuesDoc = ( )}` } +const humanizeList = (items: string[], conjunction: string): string => { + if (items.length <= 1) return items[0] ?? '' + if (items.length === 2) return items.join(` ${conjunction} `) + return `${items.slice(0, -1).join(', ')}, ${conjunction} ${items.at(-1)}` +} + +export const rubyActionAttemptStatusesDoc = ( + property: Property, + indentation: number, +): string => { + const statuses = property.actionAttemptStatuses + if (statuses == null || statuses.length === 0) return '' + const list = humanizeList( + statuses.map((status) => `\`${status}\``), + 'or', + ) + return `\n${comment( + [`Only present when \`status\` is ${list}; \`nil\` otherwise.`], + indentation, + )}` +} + +export const rubyStringArray = (values: string[]): string => + `[${values.map((value) => JSON.stringify(value)).join(', ')}]` + const nullable = ( type: string, value: { isOptional: boolean; isNullable: boolean }, diff --git a/codegen/lib/layouts/resource.ts b/codegen/lib/layouts/resource.ts index 6339d49..259c328 100644 --- a/codegen/lib/layouts/resource.ts +++ b/codegen/lib/layouts/resource.ts @@ -1,7 +1,11 @@ // Builds the template context for resource files // (lib/seam/resources/{snake_name}.rb). -import type { DiscriminatedListProperty, Property } from '@seamapi/blueprint' +import type { + ActionAttemptStatus, + DiscriminatedListProperty, + Property, +} from '@seamapi/blueprint' import { pascalCase } from 'change-case' import { convertCustomResourceName } from '../custom-resource-name-conversions.js' @@ -82,7 +86,7 @@ const isErrorOrWarningList = ( property.itemFormat === 'discriminated_object' && ['error_code', 'warning_code'].includes(property.discriminator) -const sameDescription = (properties: Property[]): string => { +export const sameDescription = (properties: Property[]): string => { const descriptions = new Set(properties.map(({ description }) => description)) return descriptions.size === 1 ? (properties[0]?.description ?? '') : '' } @@ -154,6 +158,18 @@ export const getCommonScalarProperties = ( return result } +const getScopedStatuses = ( + property: Property, + allStatuses: string[] | undefined, +): ActionAttemptStatus[] | undefined => { + const statuses: string[] | undefined = property.actionAttemptStatuses + if (statuses == null || statuses.length === 0) return undefined + if (allStatuses != null && allStatuses.every((s) => statuses.includes(s))) { + return undefined + } + return property.actionAttemptStatuses +} + const getDiscriminatorValue = ( properties: Property[], discriminator: string, @@ -242,6 +258,25 @@ const buildClass = ( const resourceListAccessors: ResourceAccessor[] = [] const takenClassNames = new Set() + const statusProperty = classProperties.find( + ({ name, format }) => name === 'status' && format === 'enum', + ) + const allStatuses = + statusProperty?.format === 'enum' + ? statusProperty.values.map(({ name }) => name) + : undefined + + const scopeToStatuses = (property: T): T => { + const statuses = getScopedStatuses(property, allStatuses) + if (statuses == null) { + if (property.actionAttemptStatuses == null) return property + const unscoped = { ...property } + delete unscoped.actionAttemptStatuses + return unscoped + } + return { ...property, actionAttemptStatuses: statuses, isNullable: true } + } + for (const property of classProperties) { const nestedProperties = getNestedProperties(property) if (nestedProperties == null) continue @@ -266,7 +301,10 @@ const buildClass = ( const destination = property.format === 'list' ? resourceListAccessors : resourceAccessors - destination.push({ ...property, className: nestedClassName }) + destination.push({ + ...scopeToStatuses(property), + className: nestedClassName, + }) const discriminated = isErrorOrWarningList(property) const nestedClass = buildClass( @@ -305,7 +343,7 @@ const buildClass = ( const toPropertyAccessor = (property: Property): PropertyAccessor => { const isAliased = property.name === 'method' && path.startsWith('event.') return { - ...property, + ...scopeToStatuses(property), accessorName: isAliased ? 'event_method' : property.name, isAliased, } diff --git a/codegen/lib/routes.ts b/codegen/lib/routes.ts index ee3e438..1c34fb1 100644 --- a/codegen/lib/routes.ts +++ b/codegen/lib/routes.ts @@ -7,6 +7,7 @@ // blueprint.namespaces. import type { + ActionAttemptStatus, Blueprint, Endpoint, Property, @@ -23,6 +24,7 @@ import { setImportsLayoutContext } from './layouts/imports.js' import { type DiscriminatedVariantSource, getCommonScalarProperties, + sameDescription, setResourceLayoutContext, } from './layouts/resource.js' import { setRoutesFileLayoutContext } from './layouts/routes-file.js' @@ -94,12 +96,41 @@ interface ResourceSource extends ResourceDocumentation { variants?: DiscriminatedVariantSource[] } +const mergeActionAttemptStatuses = ( + occurrences: Property[], +): ActionAttemptStatus[] | undefined => { + if ( + occurrences.some( + ({ actionAttemptStatuses }) => actionAttemptStatuses == null, + ) + ) { + return undefined + } + return [ + ...new Set( + occurrences.flatMap( + ({ actionAttemptStatuses }) => actionAttemptStatuses ?? [], + ), + ), + ] +} + const createActionAttemptBaseProperties = ( variants: DiscriminatedVariantSource[], ): Property[] => { const propertyLists = variants.map(({ properties }) => properties) const common = getCommonScalarProperties(propertyLists) - const nested = ['error', 'result'].flatMap((name) => { + + const nestedNames = [ + ...new Set( + propertyLists.flatMap((properties) => + properties + .filter(({ format }) => format === 'object') + .map(({ name }) => name), + ), + ), + ] + const nested = nestedNames.flatMap((name) => { const occurrences = propertyLists.map((properties) => properties.find((property) => property.name === name), ) @@ -116,17 +147,21 @@ const createActionAttemptBaseProperties = ( > const first = objects[0] if (first == null) return [] - return [ - { - ...first, - description: - name === 'error' ? 'Error associated with the action.' : '', - isNullable: true, - properties: getCommonScalarProperties( - objects.map(({ properties }) => properties), - ), - }, - ] + const property: Extract = { + ...first, + description: sameDescription(objects), + isNullable: objects.some(({ isNullable }) => isNullable), + properties: getCommonScalarProperties( + objects.map(({ properties }) => properties), + ), + } + const statuses = mergeActionAttemptStatuses(objects) + if (statuses == null) { + delete property.actionAttemptStatuses + } else { + property.actionAttemptStatuses = statuses + } + return [property] }) return [...common, ...nested].sort((a, b) => a.name.localeCompare(b.name)) @@ -160,13 +195,7 @@ const getResources = ( const variants = blueprint.actionAttempts.map((actionAttempt) => ({ discriminatorValue: actionAttempt.actionAttemptType, description: actionAttempt.description, - // Action attempts can return null for both fields while pending. The - // blueprint currently loses that per-status nullability upstream. - properties: actionAttempt.properties.map((property) => - ['error', 'result'].includes(property.name) - ? { ...property, isNullable: true } - : property, - ), + properties: actionAttempt.properties, })) resources.set('action_attempt', { description: diff --git a/lib/seam/base_resource.rb b/lib/seam/base_resource.rb index 840d342..794875d 100644 --- a/lib/seam/base_resource.rb +++ b/lib/seam/base_resource.rb @@ -78,6 +78,14 @@ def self.resource_list_accessor(attr, resource_class) end end + def self.available_only_for_statuses(attr, statuses) + unscoped = instance_method(attr) + define_method(attr) do + statuses.include?(status) ? unscoped.bind_call(self) : nil + end + end + private_class_method :available_only_for_statuses + def self.resource_accessors @resource_accessors ||= {} end diff --git a/lib/seam/resources/action_attempt.rb b/lib/seam/resources/action_attempt.rb index 3abf3e8..09e99e5 100644 --- a/lib/seam/resources/action_attempt.rb +++ b/lib/seam/resources/action_attempt.rb @@ -32,10 +32,14 @@ class Result < BaseResource # Error associated with the action. # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of the action. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -71,10 +75,14 @@ class Result < BaseResource # Error associated with the action. # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of the action. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -399,10 +407,14 @@ class Warnings < BaseResource end # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of scanning a card. If the attempt was successful, includes a snapshot of credential data read from the physical encoder, the corresponding data stored on Seam and the access system, and any associated warnings. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -644,10 +656,14 @@ class Warnings < BaseResource end # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of an encoding attempt. If the attempt was successful, includes the credential data that was encoded onto the card. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -883,10 +899,14 @@ class Warnings < BaseResource end # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of a scan to assign attempt. If the attempt was successful, includes the credential data that was scanned and assigned. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -1058,10 +1078,14 @@ class Warnings < BaseResource end # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of assigning a credential. If successful, includes the updated access method with the assigned credential. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -1094,10 +1118,14 @@ class Result < BaseResource # Error associated with the action. # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of the action. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -1130,10 +1158,14 @@ class Result < BaseResource # Error associated with the action. # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of the action. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -1166,10 +1198,14 @@ class Result < BaseResource # Error associated with the action. # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of the action. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -1202,10 +1238,14 @@ class Result < BaseResource # Error associated with the action. # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of the action. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -1238,10 +1278,14 @@ class Result < BaseResource # Error associated with the action. # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of the action. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -1274,10 +1318,14 @@ class Result < BaseResource # Error associated with the action. # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of the action. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -1310,10 +1358,14 @@ class Result < BaseResource # Error associated with the action. # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of the action. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -1346,10 +1398,14 @@ class Result < BaseResource # Error associated with the action. # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of the action. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -1381,10 +1437,14 @@ class Result < BaseResource # Error associated with the action. # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of the action. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -1419,10 +1479,14 @@ class Result < BaseResource # Error associated with the action. # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of the action. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -1454,10 +1518,14 @@ class Result < BaseResource # Error associated with the action. # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of the action. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -1492,10 +1560,14 @@ class Result < BaseResource # Error associated with the action. # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of the action. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -1530,10 +1602,14 @@ class Result < BaseResource # Error associated with the action. # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of the action. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -1565,10 +1641,14 @@ class Result < BaseResource # Error associated with the action. # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of the action. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -1603,10 +1683,14 @@ class Result < BaseResource # Error associated with the action. # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # Result of the action. # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id @@ -1623,11 +1707,14 @@ class Result < BaseResource attr_accessor :status end - # Error associated with the action. # @return [Error, nil] + # Only present when `status` is `error`; `nil` otherwise. resource_accessor :error, Error + available_only_for_statuses :error, ["error"] # @return [Result, nil] + # Only present when `status` is `success`; `nil` otherwise. resource_accessor :result, Result + available_only_for_statuses :result, ["success"] # ID of the action attempt. # @return [String] attr_accessor :action_attempt_id diff --git a/lib/seam/wait_for_action_attempt.rb b/lib/seam/wait_for_action_attempt.rb index 1f3f68f..a640cd7 100644 --- a/lib/seam/wait_for_action_attempt.rb +++ b/lib/seam/wait_for_action_attempt.rb @@ -18,8 +18,10 @@ class ActionAttemptFailedError < ActionAttemptError attr_reader :code def initialize(action_attempt) - super(action_attempt.error.message, action_attempt) - @code = action_attempt.error.type + error = action_attempt.error + message = (error && error["message"]) || "Action attempt failed" + super(message, action_attempt) + @code = error && error["type"] end end diff --git a/package-lock.json b/package-lock.json index 513bbca..3cf1cbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "@seamapi/ruby", "devDependencies": { - "@seamapi/blueprint": "^1.9.1", + "@seamapi/blueprint": "^1.10.0", "@seamapi/fake-seam-connect": "2.0.5", "@seamapi/smith": "^1.1.0", "@seamapi/types": "1.1047.0", @@ -788,9 +788,9 @@ "license": "MIT" }, "node_modules/@seamapi/blueprint": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@seamapi/blueprint/-/blueprint-1.9.1.tgz", - "integrity": "sha512-A9H3dZE9f+ZEEnOAlMl5jSlL5F/RIKkjOF8NDFbnuahAiu/trDz/RzHOGhbQd6sd0RErIyx3eG1p4HCOJDKDCA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@seamapi/blueprint/-/blueprint-1.10.0.tgz", + "integrity": "sha512-XyP6zvbhv5naWEa9T9utNLi3FVVmvUB1Htih/IjUqS3Uz0FrIIBWy7Myk5+s4uylEt+wTdtSBaA1fGOF/6ILmA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index b084288..c7508d1 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ }, "packageManager": "npm@11.19.0", "devDependencies": { - "@seamapi/blueprint": "^1.9.1", + "@seamapi/blueprint": "^1.10.0", "@seamapi/fake-seam-connect": "2.0.5", "@seamapi/smith": "^1.1.0", "@seamapi/types": "1.1047.0", diff --git a/spec/resources/action_attempt_spec.rb b/spec/resources/action_attempt_spec.rb new file mode 100644 index 0000000..5493627 --- /dev/null +++ b/spec/resources/action_attempt_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +RSpec.describe Seam::Resources::ActionAttempt do + describe "status-scoped accessors" do + it "returns nil for error and result while the attempt is pending" do + attempt = described_class.load_from_response( + "action_type" => "LOCK_DOOR", + "status" => "pending", + "error" => {"message" => "stale", "type" => "stale_error"}, + "result" => {"was_confirmed_by_device" => true} + ) + + expect(attempt.error).to be_nil + expect(attempt.result).to be_nil + end + + it "returns the result and a nil error for a successful attempt" do + attempt = described_class.load_from_response( + "action_type" => "LOCK_DOOR", + "status" => "success", + "error" => nil, + "result" => {"was_confirmed_by_device" => true} + ) + + expect(attempt.error).to be_nil + expect(attempt.result.was_confirmed_by_device).to be(true) + end + + it "returns the error and a nil result for a failed attempt" do + attempt = described_class.load_from_response( + "action_type" => "LOCK_DOOR", + "status" => "error", + "error" => {"message" => "Failed to lock", "type" => "lock_error"}, + "result" => nil + ) + + expect(attempt.result).to be_nil + expect(attempt.error.message).to eq("Failed to lock") + expect(attempt.error.type).to eq("lock_error") + end + + it "scopes error and result on unknown action types" do + attempt = described_class.load_from_response( + "action_type" => "FUTURE_ACTION", + "status" => "pending", + "error" => {"message" => "stale"}, + "result" => {} + ) + + expect(attempt).to be_an_instance_of(described_class) + expect(attempt.error).to be_nil + expect(attempt.result).to be_nil + end + end + + describe "generated documentation" do + let(:source) do + File.read( + File.expand_path("../../lib/seam/resources/action_attempt.rb", __dir__), + encoding: "UTF-8" + ) + end + + it "documents error as only present for its statuses" do + expect(source).to include("# Only present when `status` is `error`; `nil` otherwise.") + end + + it "documents result as only present for its statuses" do + expect(source).to include("# Only present when `status` is `success`; `nil` otherwise.") + end + + it "does not imply error or result are always populated" do + expect(source).not_to match(/# @return \[Error\]\s*$/) + expect(source).not_to match(/# @return \[Result\]\s*$/) + end + end +end + +RSpec.describe Seam::ActionAttemptFailedError do + def failed_attempt(error) + Seam::Resources::ActionAttempt.load_from_response( + "action_type" => "LOCK_DOOR", + "action_attempt_id" => "attempt_1", + "status" => "error", + "error" => error + ) + end + + it "carries the error message and code when the error is present" do + error = described_class.new( + failed_attempt("message" => "Failed to lock", "type" => "lock_error") + ) + + expect(error.message).to eq("Failed to lock") + expect(error.code).to eq("lock_error") + end + + it "falls back to a generic message when the error is missing" do + attempt = failed_attempt(nil) + + error = nil + expect { error = described_class.new(attempt) }.not_to raise_error + + expect(error.message).to eq("Action attempt failed") + expect(error.code).to be_nil + expect(error.action_attempt).to be(attempt) + end + + it "is raised by the resolver even when the error is missing" do + attempt = failed_attempt(nil) + + expect do + Seam::ActionAttemptResolver.resolve(attempt, nil, true) + end.to raise_error(described_class, "Action attempt failed") + end + + it "leaves the code nil when the error has no type" do + attempt = Seam::Resources::ActionAttempt.load_from_response( + "action_type" => "FUTURE_ACTION", + "status" => "error", + "error" => {"message" => "Failed"} + ) + + error = described_class.new(attempt) + + expect(error.message).to eq("Failed") + expect(error.code).to be_nil + end +end diff --git a/spec/resources/base_resource_hash_spec.rb b/spec/resources/base_resource_hash_spec.rb index e6536e6..d67ecd9 100644 --- a/spec/resources/base_resource_hash_spec.rb +++ b/spec/resources/base_resource_hash_spec.rb @@ -60,6 +60,7 @@ it "uses action-specific result classes" do scanned = Seam::Resources::ActionAttempt.load_from_response( action_type: "SCAN_CREDENTIAL", + status: "success", result: { acs_credential_on_encoder: {card_number: "123"}, acs_credential_on_seam: {acs_credential_id: "cred_1"} @@ -73,6 +74,7 @@ created = Seam::Resources::ActionAttempt.load_from_response( action_type: "CREATE_NOISE_THRESHOLD", + status: "success", result: {noise_threshold: {noise_threshold_id: "noise_1"}} )