From b92c2a7ea95995af4080b700f905d0ca8065ea40 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 01:11:44 +0000 Subject: [PATCH 1/4] feat: scope action-attempt error and result accessors by status Consume the actionAttemptStatuses property annotation from @seamapi/blueprint 1.10.0. Properties annotated with statuses are documented as only present for those statuses, typed as nullable, and their generated accessors return nil for any other status. Also guard ActionAttemptFailedError against a failed action attempt missing its error, falling back to a generic message instead of raising NoMethodError. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EdWS7o3htQ9cNxhCWL5Frp --- codegen/layouts/partials/resource-class.hbs | 20 ++- codegen/layouts/resource.hbs | 20 ++- codegen/lib/handlebars-helpers.ts | 27 ++++ codegen/lib/layouts/resource.ts | 51 +++++++- codegen/lib/routes.ts | 75 +++++++++--- lib/seam/base_resource.rb | 10 ++ lib/seam/resources/action_attempt.rb | 89 +++++++++++++- lib/seam/wait_for_action_attempt.rb | 8 +- package-lock.json | 8 +- package.json | 2 +- spec/resources/action_attempt_spec.rb | 129 ++++++++++++++++++++ spec/resources/base_resource_hash_spec.rb | 2 + 12 files changed, 402 insertions(+), 39 deletions(-) create mode 100644 spec/resources/action_attempt_spec.rb diff --git a/codegen/layouts/partials/resource-class.hbs b/codegen/layouts/partials/resource-class.hbs index d489947e..89de9aa9 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 3a4041e7..338be804 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 26077126..131439cf 100644 --- a/codegen/lib/handlebars-helpers.ts +++ b/codegen/lib/handlebars-helpers.ts @@ -49,6 +49,33 @@ 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)}` +} + +// Documents a property scoped to specific action-attempt statuses; a property +// without the annotation has the same value for every status. +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 6339d493..2566c896 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,21 @@ export const getCommonScalarProperties = ( return result } +// An action-attempt property annotated with actionAttemptStatuses only holds a +// value for the listed statuses and is null for the others. An annotation that +// covers every known status is equivalent to no annotation at all. +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 +261,27 @@ const buildClass = ( const resourceListAccessors: ResourceAccessor[] = [] const takenClassNames = new Set() + // The status property enumerates every status the resource can be in, which + // reveals when an actionAttemptStatuses annotation is actually restrictive. + 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 +306,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 +348,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 ee3e4383..dedee735 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,46 @@ interface ResourceSource extends ResourceDocumentation { variants?: DiscriminatedVariantSource[] } +// The fallback class only scopes a property to statuses when every known +// variant scopes it; the statuses are the union across the variants. If any +// variant leaves the property unscoped, the fallback leaves it unscoped too. +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) => { + + // Nested object properties every variant carries (e.g. error and result) + // fall back to the scalar fields their occurrences share. + 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 +152,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)) @@ -157,16 +197,13 @@ const getResources = ( } if (blueprint.actionAttempts.length > 0) { + // Properties annotated with actionAttemptStatuses only hold a value for + // the listed statuses (e.g. error and result while pending); the resource + // layout turns that annotation into nullable, status-scoped accessors. 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 840d3421..0cffd21a 100644 --- a/lib/seam/base_resource.rb +++ b/lib/seam/base_resource.rb @@ -78,6 +78,16 @@ def self.resource_list_accessor(attr, resource_class) end end + # Scopes an already defined accessor to the given resource statuses. The + # property only holds a value while the resource's status is one of the + # listed statuses, so the reader returns nil for any other status. + 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 + def self.resource_accessors @resource_accessors ||= {} end diff --git a/lib/seam/resources/action_attempt.rb b/lib/seam/resources/action_attempt.rb index 3abf3e8f..09e99e5a 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 1f3f68fb..faf8ade7 100644 --- a/lib/seam/wait_for_action_attempt.rb +++ b/lib/seam/wait_for_action_attempt.rb @@ -18,8 +18,12 @@ class ActionAttemptFailedError < ActionAttemptError attr_reader :code def initialize(action_attempt) - super(action_attempt.error.message, action_attempt) - @code = action_attempt.error.type + # A failed action attempt should carry an error, but guard against a + # missing one rather than raise NoMethodError while reporting a failure. + 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 513bbca9..3cf1cbcf 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 b0842882..c7508d11 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 00000000..54936271 --- /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 e6536e6f..d67ecd9b 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"}} ) From 7ff9efab1df9861875a590513d571b92904840df Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 03:00:54 +0000 Subject: [PATCH 2/4] refactor: remove narration comments Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EdWS7o3htQ9cNxhCWL5Frp --- codegen/lib/handlebars-helpers.ts | 2 -- codegen/lib/layouts/resource.ts | 5 ----- codegen/lib/routes.ts | 8 -------- lib/seam/base_resource.rb | 3 --- lib/seam/wait_for_action_attempt.rb | 2 -- 5 files changed, 20 deletions(-) diff --git a/codegen/lib/handlebars-helpers.ts b/codegen/lib/handlebars-helpers.ts index 131439cf..c794c5ae 100644 --- a/codegen/lib/handlebars-helpers.ts +++ b/codegen/lib/handlebars-helpers.ts @@ -55,8 +55,6 @@ const humanizeList = (items: string[], conjunction: string): string => { return `${items.slice(0, -1).join(', ')}, ${conjunction} ${items.at(-1)}` } -// Documents a property scoped to specific action-attempt statuses; a property -// without the annotation has the same value for every status. export const rubyActionAttemptStatusesDoc = ( property: Property, indentation: number, diff --git a/codegen/lib/layouts/resource.ts b/codegen/lib/layouts/resource.ts index 2566c896..259c3284 100644 --- a/codegen/lib/layouts/resource.ts +++ b/codegen/lib/layouts/resource.ts @@ -158,9 +158,6 @@ export const getCommonScalarProperties = ( return result } -// An action-attempt property annotated with actionAttemptStatuses only holds a -// value for the listed statuses and is null for the others. An annotation that -// covers every known status is equivalent to no annotation at all. const getScopedStatuses = ( property: Property, allStatuses: string[] | undefined, @@ -261,8 +258,6 @@ const buildClass = ( const resourceListAccessors: ResourceAccessor[] = [] const takenClassNames = new Set() - // The status property enumerates every status the resource can be in, which - // reveals when an actionAttemptStatuses annotation is actually restrictive. const statusProperty = classProperties.find( ({ name, format }) => name === 'status' && format === 'enum', ) diff --git a/codegen/lib/routes.ts b/codegen/lib/routes.ts index dedee735..1c34fb15 100644 --- a/codegen/lib/routes.ts +++ b/codegen/lib/routes.ts @@ -96,9 +96,6 @@ interface ResourceSource extends ResourceDocumentation { variants?: DiscriminatedVariantSource[] } -// The fallback class only scopes a property to statuses when every known -// variant scopes it; the statuses are the union across the variants. If any -// variant leaves the property unscoped, the fallback leaves it unscoped too. const mergeActionAttemptStatuses = ( occurrences: Property[], ): ActionAttemptStatus[] | undefined => { @@ -124,8 +121,6 @@ const createActionAttemptBaseProperties = ( const propertyLists = variants.map(({ properties }) => properties) const common = getCommonScalarProperties(propertyLists) - // Nested object properties every variant carries (e.g. error and result) - // fall back to the scalar fields their occurrences share. const nestedNames = [ ...new Set( propertyLists.flatMap((properties) => @@ -197,9 +192,6 @@ const getResources = ( } if (blueprint.actionAttempts.length > 0) { - // Properties annotated with actionAttemptStatuses only hold a value for - // the listed statuses (e.g. error and result while pending); the resource - // layout turns that annotation into nullable, status-scoped accessors. const variants = blueprint.actionAttempts.map((actionAttempt) => ({ discriminatorValue: actionAttempt.actionAttemptType, description: actionAttempt.description, diff --git a/lib/seam/base_resource.rb b/lib/seam/base_resource.rb index 0cffd21a..20cfb65d 100644 --- a/lib/seam/base_resource.rb +++ b/lib/seam/base_resource.rb @@ -78,9 +78,6 @@ def self.resource_list_accessor(attr, resource_class) end end - # Scopes an already defined accessor to the given resource statuses. The - # property only holds a value while the resource's status is one of the - # listed statuses, so the reader returns nil for any other status. def self.available_only_for_statuses(attr, statuses) unscoped = instance_method(attr) define_method(attr) do diff --git a/lib/seam/wait_for_action_attempt.rb b/lib/seam/wait_for_action_attempt.rb index faf8ade7..a640cd77 100644 --- a/lib/seam/wait_for_action_attempt.rb +++ b/lib/seam/wait_for_action_attempt.rb @@ -18,8 +18,6 @@ class ActionAttemptFailedError < ActionAttemptError attr_reader :code def initialize(action_attempt) - # A failed action attempt should carry an error, but guard against a - # missing one rather than raise NoMethodError while reporting a failure. error = action_attempt.error message = (error && error["message"]) || "Action attempt failed" super(message, action_attempt) From 94e2593bfa744c885591af5702bae6d8a98363c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 16:02:37 +0000 Subject: [PATCH 3/4] docs: document status-dependent action attempt values Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EdWS7o3htQ9cNxhCWL5Frp --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 5266ce93..2259b6c7 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: From 76a12ad913eede944543de352c8708320f1ce147 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 20:31:57 +0000 Subject: [PATCH 4/4] refactor: make available_only_for_statuses private Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EdWS7o3htQ9cNxhCWL5Frp --- lib/seam/base_resource.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/seam/base_resource.rb b/lib/seam/base_resource.rb index 20cfb65d..794875d6 100644 --- a/lib/seam/base_resource.rb +++ b/lib/seam/base_resource.rb @@ -84,6 +84,7 @@ def self.available_only_for_statuses(attr, statuses) statuses.include?(status) ? unscoped.bind_call(self) : nil end end + private_class_method :available_only_for_statuses def self.resource_accessors @resource_accessors ||= {}