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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,31 @@ succeeds and returns the finished attempt, raising
var actionAttempt = await seam.Locks.UnlockDoorAsync(new() { DeviceId = deviceId });
```

Each action attempt deserializes to a subclass for its `action_type` and
`status` pair, e.g. `ActionAttemptUnlockDoorSuccess`. The `Error` and `Result`
properties are declared only on the status subclass that populates them, so
pattern match on the subclass to read them:

```csharp
var actionAttempt = await seam.Locks.UnlockDoorAsync(
new() { DeviceId = deviceId },
waitForActionAttempt: false
);

switch (actionAttempt)
{
case ActionAttemptUnlockDoorSuccess success:
Console.WriteLine(success.Result.WasConfirmedByDevice);
break;
case ActionAttemptUnlockDoorError error:
Console.WriteLine(error.Error.Message);
break;
case ActionAttemptUnlockDoorPending:
Console.WriteLine("Still pending");
break;
}
```

Configure or disable waiting per client or per call with `ActionAttemptWait`:

```csharp
Expand Down
9 changes: 8 additions & 1 deletion codegen/layouts/partials/oneof-union.hbs
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
{{> documentation}}
[JsonConverter(typeof(SeamUnionConverter))]
[SeamUnion("{{discriminatorSnake}}")]
{{#each knownSubTypes}}
[SeamUnionVariant("{{value}}", typeof({{typeName}}))]
{{/each}}
[SeamUnionFallback(typeof({{unrecognizedTypeName}}))]
public abstract record {{className}}
public abstract record {{className}}{{#if baseClass}} : {{baseClass}}{{/if}}
{
{{#unless inheritsDiscriminator}}
/// <summary>The value of the <c>{{discriminatorSnake}}</c> discriminator.</summary>
public abstract string {{discriminatorPascal}} { get; }
{{/unless}}
{{#each baseProps}}

{{> data-member this}}
{{/each}}
}
{{#each subclasses}}

{{#if (eq kind "union")}}
{{> oneof-union this}}
{{else}}
{{> model-class this}}
{{/if}}
{{/each}}
165 changes: 136 additions & 29 deletions codegen/lib/build-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@

import type {
ActionAttempt,
ActionAttemptStatus,
Endpoint,
EnumProperty,
EventResource,
Parameter,
Property,
Expand Down Expand Up @@ -99,6 +101,7 @@ interface Variant {
fields: Field[]
description?: string
deprecationMessage?: string
buildAsUnion?: (subName: string, omitNames: Set<string>) => CsUnion
}

const normalizeEnumValues = (
Expand Down Expand Up @@ -306,7 +309,12 @@ interface BuildClassOptions {
resourceType: 'response' | 'request' | 'model'
// When set, the class is a discriminated-union subclass: the discriminator
// property is emitted as a get-only override with a constant value.
discriminator?: { name: string; value: string; base: string }
discriminator?: {
name: string
value: string
base: string
declareProperty?: boolean
}
// Field names declared concretely on the union base; omitted from subclasses.
omitNames?: Set<string> | undefined
documentation?: string
Expand Down Expand Up @@ -430,7 +438,7 @@ const buildClass = (
if (omitNames?.has(field.name) ?? false) continue
properties.push(mapField(field))
}
if (discriminator != null) {
if (discriminator != null && (discriminator.declareProperty ?? true)) {
properties.unshift({
pascalName: pascalCase(discriminator.name),
snakeName: snakeCase(discriminator.name),
Expand Down Expand Up @@ -459,9 +467,14 @@ interface BuildUnionOptions {
resourceType: 'response' | 'request' | 'model'
// Field names removed from every variant in favor of `extraBaseProps`
// declared on the base with a shared type, e.g. the action attempt
// status/error contract the runtime resolver depends on.
// status contract the runtime resolver depends on.
omitFieldNames?: string[]
extraBaseProps?: CsProperty[]
baseClass?: string
inheritsDiscriminator?: boolean
leadingBaseProps?: CsProperty[]
documentation?: string
obsoleteMessage?: string
}

const buildUnion = (
Expand All @@ -470,7 +483,16 @@ const buildUnion = (
variants: Variant[],
options: BuildUnionOptions,
): CsUnion => {
const { resourceType, omitFieldNames = [], extraBaseProps = [] } = options
const {
resourceType,
omitFieldNames = [],
extraBaseProps = [],
baseClass,
inheritsDiscriminator,
leadingBaseProps = [],
documentation,
obsoleteMessage,
} = options
const omitted = new Set(omitFieldNames)

// Lift properties shared by every variant onto the base so consumers can
Expand Down Expand Up @@ -507,6 +529,7 @@ const buildUnion = (
])

const baseProps: CsProperty[] = [
...leadingBaseProps,
...liftedFields.map((field): CsProperty => {
const type = primType(field) as string
return {
Expand All @@ -528,17 +551,25 @@ const buildUnion = (
...extraBaseProps,
]

const subclasses: CsClass[] = []
const subclasses: Array<CsClass | CsUnion> = []
const known: Array<[string, string]> = []

for (const variant of variants) {
const subName = pascalCase(className + pascalCase(variant.value))
if (variant.buildAsUnion != null) {
subclasses.push(
variant.buildAsUnion(subName, new Set([...omitNames, discriminator])),
)
known.push([subName, variant.value])
continue
}
const built = buildClass(subName, variant.fields, {
resourceType,
discriminator: {
name: discriminator,
value: variant.value,
base: className,
...(inheritsDiscriminator ? { declareProperty: false } : {}),
},
omitNames,
...(variant.description != null
Expand All @@ -559,6 +590,7 @@ const buildUnion = (
name: discriminator,
value: 'unrecognized',
base: className,
...(inheritsDiscriminator ? { declareProperty: false } : {}),
},
})
subclasses.push({ ...fallback.main, isUnrecognizedFallback: true })
Expand All @@ -568,12 +600,16 @@ const buildUnion = (
return {
kind: 'union',
className,
...(baseClass != null ? { baseClass } : {}),
discriminatorSnake: discriminator,
discriminatorPascal: pascalCase(discriminator),
...(inheritsDiscriminator ? { inheritsDiscriminator } : {}),
knownSubTypes,
unrecognizedTypeName,
baseProps,
subclasses,
...(documentation != null ? { documentation } : {}),
...(obsoleteMessage != null ? { obsoleteMessage } : {}),
}
}

Expand All @@ -591,29 +627,109 @@ export const buildModelFile = (
return { name, file: { decls: [built.main, ...built.siblings] } }
}

const actionAttemptRuntimeTypes: Record<string, string> = {
error: 'ActionAttemptError',
}

const normalizeActionAttemptField = (property: Property): Field => {
const runtimeType = actionAttemptRuntimeTypes[property.name]
const field = normalizeProperty(property)
if (runtimeType == null) return field
return { ...field, kind: { t: 'ref', cs: runtimeType } }
}

const findActionAttemptStatusProperty = (
actionAttempt: ActionAttempt,
): EnumProperty | undefined =>
actionAttempt.properties.find(
(property): property is EnumProperty =>
property.name === 'status' && property.format === 'enum',
)

const buildActionAttemptStatusUnion = (
actionAttempt: ActionAttempt,
statusProperty: EnumProperty,
subName: string,
omitNames: Set<string>,
): CsUnion =>
buildUnion(
subName,
statusProperty.name,
statusProperty.values.map(({ name }) => {
const status = name as ActionAttemptStatus
return {
value: status,
fields: actionAttempt.properties.flatMap((property) => {
if (property === statusProperty || omitNames.has(property.name)) {
return []
}
const statuses = property.actionAttemptStatuses
if (statuses != null && !statuses.includes(status)) return []
return [normalizeActionAttemptField(property)]
}),
}
}),
{
resourceType: 'model',
baseClass: 'ActionAttempt',
inheritsDiscriminator: true,
leadingBaseProps: [
{
pascalName: 'ActionType',
snakeName: 'action_type',
type: 'string',
isRequired: false,
isOverride: true,
getOnly: true,
initializer: `"${actionAttempt.actionAttemptType}"`,
},
],
documentation: actionAttempt.description,
...(actionAttempt.isDeprecated
? {
obsoleteMessage: actionAttempt.deprecationMessage || 'Deprecated.',
}
: {}),
},
)

export const buildActionAttemptFile = (
actionAttempts: ActionAttempt[],
): { name: string; file: CsModelFile } => {
// The status and error of every action attempt share one wire shape, so they
// are declared once on the base with the runtime-owned ActionAttemptStatus
// and ActionAttemptError types the action attempt resolver depends on.
// The status of every action attempt shares one wire shape, so it is
// declared once on the base with the runtime-owned ActionAttemptStatus type
// the action attempt resolver depends on.
const union = buildUnion(
'ActionAttempt',
'action_type',
actionAttempts.map((actionAttempt) => ({
value: actionAttempt.actionAttemptType,
fields: actionAttempt.properties.map(normalizeProperty),
description: actionAttempt.description,
...(actionAttempt.isDeprecated
? {
deprecationMessage:
actionAttempt.deprecationMessage || 'Deprecated.',
}
: {}),
})),
actionAttempts.map((actionAttempt) => {
const statusProperty = findActionAttemptStatusProperty(actionAttempt)
return {
value: actionAttempt.actionAttemptType,
fields: actionAttempt.properties.map(normalizeActionAttemptField),
description: actionAttempt.description,
...(actionAttempt.isDeprecated
? {
deprecationMessage:
actionAttempt.deprecationMessage || 'Deprecated.',
}
: {}),
...(statusProperty == null
? {}
: {
buildAsUnion: (subName: string, omitNames: Set<string>) =>
buildActionAttemptStatusUnion(
actionAttempt,
statusProperty,
subName,
omitNames,
),
}),
}
}),
{
resourceType: 'model',
omitFieldNames: ['status', 'error'],
omitFieldNames: ['status'],
extraBaseProps: [
{
pascalName: 'Status',
Expand All @@ -624,15 +740,6 @@ export const buildActionAttemptFile = (
getOnly: false,
documentation: 'The status of the action attempt.',
},
{
pascalName: 'Error',
snakeName: 'error',
type: 'ActionAttemptError?',
isRequired: false,
isOverride: false,
getOnly: false,
documentation: 'The error of a failed action attempt, or null.',
},
],
},
)
Expand Down
6 changes: 5 additions & 1 deletion codegen/lib/class-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,10 @@ export interface CsClass {
export interface CsUnion {
kind: 'union'
className: string
baseClass?: string
discriminatorSnake: string
discriminatorPascal: string
inheritsDiscriminator?: boolean
// [SeamUnionVariant] attributes, in variant definition order.
knownSubTypes: Array<{ typeName: string; value: string }>
unrecognizedTypeName: string
Expand All @@ -81,7 +83,9 @@ export interface CsUnion {
baseProps: CsProperty[]
// Concrete subclasses followed by the Unrecognized fallback, in definition
// order.
subclasses: CsClass[]
subclasses: Array<CsClass | CsUnion>
documentation?: string
obsoleteMessage?: string
}

export type CsDecl = CsClass | CsUnion
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
},
"packageManager": "npm@11.19.0",
"devDependencies": {
"@seamapi/blueprint": "^1.8.0",
"@seamapi/blueprint": "^1.10.0",
"@seamapi/fake-seam-connect": "2.0.5",
"@seamapi/smith": "^1.1.0",
"@seamapi/types": "1.1034.0",
Expand Down
Loading
Loading