Skip to content

Commit b92c2a7

Browse files
committed
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EdWS7o3htQ9cNxhCWL5Frp
1 parent b2caffd commit b92c2a7

12 files changed

Lines changed: 402 additions & 39 deletions

File tree

‎codegen/layouts/partials/resource-class.hbs‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,27 @@
1212
{{#if description}}
1313
{{{rubyDoc description ../docIndent}}}
1414
{{/if}}
15-
{{../bodyIndent}}# @return [{{rubyResourceType this}}]{{{rubyEnumValuesDoc this ../docIndent}}}
15+
{{../bodyIndent}}# @return [{{rubyResourceType this}}]{{{rubyEnumValuesDoc this ../docIndent}}}{{{rubyActionAttemptStatusesDoc this ../docIndent}}}
1616
{{../bodyIndent}}resource_accessor :{{name}}, {{className}}
17+
{{#if actionAttemptStatuses}}
18+
{{../bodyIndent}}available_only_for_statuses :{{name}}, {{rubyStringArray actionAttemptStatuses}}
19+
{{/if}}
1720
{{/each}}
1821
{{#each resourceListAccessors}}
1922
{{#if description}}
2023
{{{rubyDoc description ../docIndent}}}
2124
{{/if}}
22-
{{../bodyIndent}}# @return [{{rubyResourceType this}}]{{{rubyEnumValuesDoc this ../docIndent}}}
25+
{{../bodyIndent}}# @return [{{rubyResourceType this}}]{{{rubyEnumValuesDoc this ../docIndent}}}{{{rubyActionAttemptStatusesDoc this ../docIndent}}}
2326
{{../bodyIndent}}resource_list_accessor :{{name}}, {{className}}
27+
{{#if actionAttemptStatuses}}
28+
{{../bodyIndent}}available_only_for_statuses :{{name}}, {{rubyStringArray actionAttemptStatuses}}
29+
{{/if}}
2430
{{/each}}
2531
{{#each accessors}}
2632
{{#if description}}
2733
{{{rubyDoc description ../docIndent}}}
2834
{{/if}}
29-
{{../bodyIndent}}# @return [{{rubyPropertyType this}}]{{{rubyEnumValuesDoc this ../docIndent}}}
35+
{{../bodyIndent}}# @return [{{rubyPropertyType this}}]{{{rubyEnumValuesDoc this ../docIndent}}}{{{rubyActionAttemptStatusesDoc this ../docIndent}}}
3036
{{#if isDeprecated}}
3137
{{{rubyDeprecatedDoc this ../docIndent}}}
3238
{{/if}}
@@ -35,16 +41,22 @@
3541
{{else}}
3642
{{../bodyIndent}}attr_accessor :{{accessorName}}
3743
{{/if}}
44+
{{#if actionAttemptStatuses}}
45+
{{../bodyIndent}}available_only_for_statuses :{{accessorName}}, {{rubyStringArray actionAttemptStatuses}}
46+
{{/if}}
3847
{{/each}}
3948
{{#each dateAccessors}}
4049
{{#if description}}
4150
{{{rubyDoc description ../docIndent}}}
4251
{{/if}}
43-
{{../bodyIndent}}# @return [{{rubyPropertyType this}}]{{{rubyEnumValuesDoc this ../docIndent}}}
52+
{{../bodyIndent}}# @return [{{rubyPropertyType this}}]{{{rubyEnumValuesDoc this ../docIndent}}}{{{rubyActionAttemptStatusesDoc this ../docIndent}}}
4453
{{#if isDeprecated}}
4554
{{{rubyDeprecatedDoc this ../docIndent}}}
4655
{{/if}}
4756
{{../bodyIndent}}date_accessor :{{accessorName}}
57+
{{#if actionAttemptStatuses}}
58+
{{../bodyIndent}}available_only_for_statuses :{{accessorName}}, {{rubyStringArray actionAttemptStatuses}}
59+
{{/if}}
4860
{{/each}}
4961
{{#if discriminator}}
5062

‎codegen/layouts/resource.hbs‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,27 @@ module Seam
1919
{{#if description}}
2020
{{{rubyDoc description 6}}}
2121
{{/if}}
22-
# @return [{{rubyResourceType this}}]{{{rubyEnumValuesDoc this 6}}}
22+
# @return [{{rubyResourceType this}}]{{{rubyEnumValuesDoc this 6}}}{{{rubyActionAttemptStatusesDoc this 6}}}
2323
resource_accessor :{{name}}, {{className}}
24+
{{#if actionAttemptStatuses}}
25+
available_only_for_statuses :{{name}}, {{rubyStringArray actionAttemptStatuses}}
26+
{{/if}}
2427
{{/each}}
2528
{{#each resourceListAccessors}}
2629
{{#if description}}
2730
{{{rubyDoc description 6}}}
2831
{{/if}}
29-
# @return [{{rubyResourceType this}}]{{{rubyEnumValuesDoc this 6}}}
32+
# @return [{{rubyResourceType this}}]{{{rubyEnumValuesDoc this 6}}}{{{rubyActionAttemptStatusesDoc this 6}}}
3033
resource_list_accessor :{{name}}, {{className}}
34+
{{#if actionAttemptStatuses}}
35+
available_only_for_statuses :{{name}}, {{rubyStringArray actionAttemptStatuses}}
36+
{{/if}}
3137
{{/each}}
3238
{{#each accessors}}
3339
{{#if description}}
3440
{{{rubyDoc description 6}}}
3541
{{/if}}
36-
# @return [{{rubyPropertyType this}}]{{{rubyEnumValuesDoc this 6}}}
42+
# @return [{{rubyPropertyType this}}]{{{rubyEnumValuesDoc this 6}}}{{{rubyActionAttemptStatusesDoc this 6}}}
3743
{{#if isDeprecated}}
3844
{{{rubyDeprecatedDoc this 6}}}
3945
{{/if}}
@@ -42,17 +48,23 @@ module Seam
4248
{{else}}
4349
attr_accessor :{{accessorName}}
4450
{{/if}}
51+
{{#if actionAttemptStatuses}}
52+
available_only_for_statuses :{{accessorName}}, {{rubyStringArray actionAttemptStatuses}}
53+
{{/if}}
4554
{{/each}}
4655
{{#each dateAccessors}}
4756

4857
{{#if description}}
4958
{{{rubyDoc description 6}}}
5059
{{/if}}
51-
# @return [{{rubyPropertyType this}}]{{{rubyEnumValuesDoc this 6}}}
60+
# @return [{{rubyPropertyType this}}]{{{rubyEnumValuesDoc this 6}}}{{{rubyActionAttemptStatusesDoc this 6}}}
5261
{{#if isDeprecated}}
5362
{{{rubyDeprecatedDoc this 6}}}
5463
{{/if}}
5564
date_accessor :{{accessorName}}
65+
{{#if actionAttemptStatuses}}
66+
available_only_for_statuses :{{accessorName}}, {{rubyStringArray actionAttemptStatuses}}
67+
{{/if}}
5668
{{/each}}
5769
{{#if discriminator}}
5870

‎codegen/lib/handlebars-helpers.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,33 @@ export const rubyEnumValuesDoc = (
4949
)}`
5050
}
5151

52+
const humanizeList = (items: string[], conjunction: string): string => {
53+
if (items.length <= 1) return items[0] ?? ''
54+
if (items.length === 2) return items.join(` ${conjunction} `)
55+
return `${items.slice(0, -1).join(', ')}, ${conjunction} ${items.at(-1)}`
56+
}
57+
58+
// Documents a property scoped to specific action-attempt statuses; a property
59+
// without the annotation has the same value for every status.
60+
export const rubyActionAttemptStatusesDoc = (
61+
property: Property,
62+
indentation: number,
63+
): string => {
64+
const statuses = property.actionAttemptStatuses
65+
if (statuses == null || statuses.length === 0) return ''
66+
const list = humanizeList(
67+
statuses.map((status) => `\`${status}\``),
68+
'or',
69+
)
70+
return `\n${comment(
71+
[`Only present when \`status\` is ${list}; \`nil\` otherwise.`],
72+
indentation,
73+
)}`
74+
}
75+
76+
export const rubyStringArray = (values: string[]): string =>
77+
`[${values.map((value) => JSON.stringify(value)).join(', ')}]`
78+
5279
const nullable = (
5380
type: string,
5481
value: { isOptional: boolean; isNullable: boolean },

‎codegen/lib/layouts/resource.ts‎

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
// Builds the template context for resource files
22
// (lib/seam/resources/{snake_name}.rb).
33

4-
import type { DiscriminatedListProperty, Property } from '@seamapi/blueprint'
4+
import type {
5+
ActionAttemptStatus,
6+
DiscriminatedListProperty,
7+
Property,
8+
} from '@seamapi/blueprint'
59
import { pascalCase } from 'change-case'
610

711
import { convertCustomResourceName } from '../custom-resource-name-conversions.js'
@@ -82,7 +86,7 @@ const isErrorOrWarningList = (
8286
property.itemFormat === 'discriminated_object' &&
8387
['error_code', 'warning_code'].includes(property.discriminator)
8488

85-
const sameDescription = (properties: Property[]): string => {
89+
export const sameDescription = (properties: Property[]): string => {
8690
const descriptions = new Set(properties.map(({ description }) => description))
8791
return descriptions.size === 1 ? (properties[0]?.description ?? '') : ''
8892
}
@@ -154,6 +158,21 @@ export const getCommonScalarProperties = (
154158
return result
155159
}
156160

161+
// An action-attempt property annotated with actionAttemptStatuses only holds a
162+
// value for the listed statuses and is null for the others. An annotation that
163+
// covers every known status is equivalent to no annotation at all.
164+
const getScopedStatuses = (
165+
property: Property,
166+
allStatuses: string[] | undefined,
167+
): ActionAttemptStatus[] | undefined => {
168+
const statuses: string[] | undefined = property.actionAttemptStatuses
169+
if (statuses == null || statuses.length === 0) return undefined
170+
if (allStatuses != null && allStatuses.every((s) => statuses.includes(s))) {
171+
return undefined
172+
}
173+
return property.actionAttemptStatuses
174+
}
175+
157176
const getDiscriminatorValue = (
158177
properties: Property[],
159178
discriminator: string,
@@ -242,6 +261,27 @@ const buildClass = (
242261
const resourceListAccessors: ResourceAccessor[] = []
243262
const takenClassNames = new Set<string>()
244263

264+
// The status property enumerates every status the resource can be in, which
265+
// reveals when an actionAttemptStatuses annotation is actually restrictive.
266+
const statusProperty = classProperties.find(
267+
({ name, format }) => name === 'status' && format === 'enum',
268+
)
269+
const allStatuses =
270+
statusProperty?.format === 'enum'
271+
? statusProperty.values.map(({ name }) => name)
272+
: undefined
273+
274+
const scopeToStatuses = <T extends Property>(property: T): T => {
275+
const statuses = getScopedStatuses(property, allStatuses)
276+
if (statuses == null) {
277+
if (property.actionAttemptStatuses == null) return property
278+
const unscoped = { ...property }
279+
delete unscoped.actionAttemptStatuses
280+
return unscoped
281+
}
282+
return { ...property, actionAttemptStatuses: statuses, isNullable: true }
283+
}
284+
245285
for (const property of classProperties) {
246286
const nestedProperties = getNestedProperties(property)
247287
if (nestedProperties == null) continue
@@ -266,7 +306,10 @@ const buildClass = (
266306

267307
const destination =
268308
property.format === 'list' ? resourceListAccessors : resourceAccessors
269-
destination.push({ ...property, className: nestedClassName })
309+
destination.push({
310+
...scopeToStatuses(property),
311+
className: nestedClassName,
312+
})
270313

271314
const discriminated = isErrorOrWarningList(property)
272315
const nestedClass = buildClass(
@@ -305,7 +348,7 @@ const buildClass = (
305348
const toPropertyAccessor = (property: Property): PropertyAccessor => {
306349
const isAliased = property.name === 'method' && path.startsWith('event.')
307350
return {
308-
...property,
351+
...scopeToStatuses(property),
309352
accessorName: isAliased ? 'event_method' : property.name,
310353
isAliased,
311354
}

‎codegen/lib/routes.ts‎

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
// blueprint.namespaces.
88

99
import type {
10+
ActionAttemptStatus,
1011
Blueprint,
1112
Endpoint,
1213
Property,
@@ -23,6 +24,7 @@ import { setImportsLayoutContext } from './layouts/imports.js'
2324
import {
2425
type DiscriminatedVariantSource,
2526
getCommonScalarProperties,
27+
sameDescription,
2628
setResourceLayoutContext,
2729
} from './layouts/resource.js'
2830
import { setRoutesFileLayoutContext } from './layouts/routes-file.js'
@@ -94,12 +96,46 @@ interface ResourceSource extends ResourceDocumentation {
9496
variants?: DiscriminatedVariantSource[]
9597
}
9698

99+
// The fallback class only scopes a property to statuses when every known
100+
// variant scopes it; the statuses are the union across the variants. If any
101+
// variant leaves the property unscoped, the fallback leaves it unscoped too.
102+
const mergeActionAttemptStatuses = (
103+
occurrences: Property[],
104+
): ActionAttemptStatus[] | undefined => {
105+
if (
106+
occurrences.some(
107+
({ actionAttemptStatuses }) => actionAttemptStatuses == null,
108+
)
109+
) {
110+
return undefined
111+
}
112+
return [
113+
...new Set(
114+
occurrences.flatMap(
115+
({ actionAttemptStatuses }) => actionAttemptStatuses ?? [],
116+
),
117+
),
118+
]
119+
}
120+
97121
const createActionAttemptBaseProperties = (
98122
variants: DiscriminatedVariantSource[],
99123
): Property[] => {
100124
const propertyLists = variants.map(({ properties }) => properties)
101125
const common = getCommonScalarProperties(propertyLists)
102-
const nested = ['error', 'result'].flatMap((name) => {
126+
127+
// Nested object properties every variant carries (e.g. error and result)
128+
// fall back to the scalar fields their occurrences share.
129+
const nestedNames = [
130+
...new Set(
131+
propertyLists.flatMap((properties) =>
132+
properties
133+
.filter(({ format }) => format === 'object')
134+
.map(({ name }) => name),
135+
),
136+
),
137+
]
138+
const nested = nestedNames.flatMap((name) => {
103139
const occurrences = propertyLists.map((properties) =>
104140
properties.find((property) => property.name === name),
105141
)
@@ -116,17 +152,21 @@ const createActionAttemptBaseProperties = (
116152
>
117153
const first = objects[0]
118154
if (first == null) return []
119-
return [
120-
{
121-
...first,
122-
description:
123-
name === 'error' ? 'Error associated with the action.' : '',
124-
isNullable: true,
125-
properties: getCommonScalarProperties(
126-
objects.map(({ properties }) => properties),
127-
),
128-
},
129-
]
155+
const property: Extract<Property, { format: 'object' }> = {
156+
...first,
157+
description: sameDescription(objects),
158+
isNullable: objects.some(({ isNullable }) => isNullable),
159+
properties: getCommonScalarProperties(
160+
objects.map(({ properties }) => properties),
161+
),
162+
}
163+
const statuses = mergeActionAttemptStatuses(objects)
164+
if (statuses == null) {
165+
delete property.actionAttemptStatuses
166+
} else {
167+
property.actionAttemptStatuses = statuses
168+
}
169+
return [property]
130170
})
131171

132172
return [...common, ...nested].sort((a, b) => a.name.localeCompare(b.name))
@@ -157,16 +197,13 @@ const getResources = (
157197
}
158198

159199
if (blueprint.actionAttempts.length > 0) {
200+
// Properties annotated with actionAttemptStatuses only hold a value for
201+
// the listed statuses (e.g. error and result while pending); the resource
202+
// layout turns that annotation into nullable, status-scoped accessors.
160203
const variants = blueprint.actionAttempts.map((actionAttempt) => ({
161204
discriminatorValue: actionAttempt.actionAttemptType,
162205
description: actionAttempt.description,
163-
// Action attempts can return null for both fields while pending. The
164-
// blueprint currently loses that per-status nullability upstream.
165-
properties: actionAttempt.properties.map((property) =>
166-
['error', 'result'].includes(property.name)
167-
? { ...property, isNullable: true }
168-
: property,
169-
),
206+
properties: actionAttempt.properties,
170207
}))
171208
resources.set('action_attempt', {
172209
description:

‎lib/seam/base_resource.rb‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,16 @@ def self.resource_list_accessor(attr, resource_class)
7878
end
7979
end
8080

81+
# Scopes an already defined accessor to the given resource statuses. The
82+
# property only holds a value while the resource's status is one of the
83+
# listed statuses, so the reader returns nil for any other status.
84+
def self.available_only_for_statuses(attr, statuses)
85+
unscoped = instance_method(attr)
86+
define_method(attr) do
87+
statuses.include?(status) ? unscoped.bind_call(self) : nil
88+
end
89+
end
90+
8191
def self.resource_accessors
8292
@resource_accessors ||= {}
8393
end

0 commit comments

Comments
 (0)