Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
20 changes: 16 additions & 4 deletions codegen/layouts/partials/resource-class.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand All @@ -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}}

Expand Down
20 changes: 16 additions & 4 deletions codegen/layouts/resource.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand All @@ -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}}

Expand Down
25 changes: 25 additions & 0 deletions codegen/lib/handlebars-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
46 changes: 42 additions & 4 deletions codegen/lib/layouts/resource.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 ?? '') : ''
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -242,6 +258,25 @@ const buildClass = (
const resourceListAccessors: ResourceAccessor[] = []
const takenClassNames = new Set<string>()

const statusProperty = classProperties.find(
({ name, format }) => name === 'status' && format === 'enum',
)
const allStatuses =
statusProperty?.format === 'enum'
? statusProperty.values.map(({ name }) => name)
: undefined

const scopeToStatuses = <T extends Property>(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
Expand All @@ -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(
Expand Down Expand Up @@ -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,
}
Expand Down
67 changes: 48 additions & 19 deletions codegen/lib/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// blueprint.namespaces.

import type {
ActionAttemptStatus,
Blueprint,
Endpoint,
Property,
Expand All @@ -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'
Expand Down Expand Up @@ -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),
)
Expand All @@ -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<Property, { format: 'object' }> = {
...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))
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions lib/seam/base_resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading