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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,28 @@ try {
}
```

Each action attempt deserializes to a class for its `action_type` and `status`
pair, e.g., `Seam\Resources\ActionAttempt\UnlockDoor\Success`. The `error` and
`result` properties have the `null` type except on the status class that
populates them, so narrow with `instanceof` before reading them:

```php
use Seam\Resources\ActionAttempt\UnlockDoor;

$action_attempt = $seam->locks->unlock_door(
device_id: $device_id,
wait_for_action_attempt: false
);

if ($action_attempt instanceof UnlockDoor\Success) {
var_dump($action_attempt->result); // The result is populated here.
}

if ($action_attempt instanceof UnlockDoor\Error) {
print $action_attempt->error->message; // The error is populated here.
}
```

Waiting may be disabled for the whole client:

```php
Expand Down
5 changes: 5 additions & 0 deletions codegen/lib/layouts/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ const generateFromJsonProp = (property: ResourceClassProperty): string => {

case 'value':
return `${name}: $json->${name} ?? null,`
case 'null':
return `${name}: null,`
}
}

Expand Down Expand Up @@ -99,6 +101,9 @@ const generateConstructorParam = (
: `${property.phpDocType}|null`
break
}
case 'null':
type = 'null'
break
}

return {
Expand Down
164 changes: 130 additions & 34 deletions codegen/lib/resource-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// one final class per variant, and an unknown-discriminant fallback.

import type {
ActionAttemptStatus,
Blueprint,
EnumProperty,
Property,
Expand All @@ -20,6 +21,9 @@ export type ResourceClassProperty =
phpType: string
phpDocType: string
} & ResourceClassPropertyMetadata)
| ({
kind: 'null'
} & ResourceClassPropertyMetadata)
| ({
kind: 'record'
phpType: string
Expand Down Expand Up @@ -242,7 +246,7 @@ export const createResourceModel = (blueprint: Blueprint): ResourceModel => {
isDeprecated: false,
deprecationMessage: '',
},
true,
{ isActionAttempt: true },
)
} else {
const resource = resources.get(resourceType)
Expand Down Expand Up @@ -296,6 +300,10 @@ const buildClass = (
const nestedPath = `${path}.${property.name}`
const nestedClassName = pascalCase(property.name)

if (isRenderedAsNull(property)) {
return { ...metadata, kind: 'null' }
}

if (property.format === 'enum') {
assertAvailableName(
nestedClassName,
Expand Down Expand Up @@ -400,6 +408,13 @@ const buildClass = (
}
}

interface DiscriminatedClassOptions {
isActionAttempt?: boolean
extendsName?: string
inheritedProperties?: ResourceClassProperty[]
discriminantEnumType?: string
}

const buildDiscriminatedClass = (
className: string,
namespace: string,
Expand All @@ -408,7 +423,7 @@ const buildDiscriminatedClass = (
path: string,
depth: number,
docs: ClassDocs,
actionAttempt = false,
options: DiscriminatedClassOptions = {},
): BuiltDeclaration => {
assertDepth(path, depth)
if (variants.length === 0) {
Expand All @@ -434,11 +449,16 @@ const buildDiscriminatedClass = (
const candidate = variant.properties.find(
({ name }) => name === property.name,
)
if (candidate == null) return false
if (
(options.isActionAttempt ?? false) &&
property.actionAttemptStatuses != null
) {
return false
}
return (
candidate != null &&
(property.name === discriminator ||
(actionAttempt && property.name === 'error') ||
propertyShape(candidate) === propertyShape(property))
property.name === discriminator ||
propertyShape(candidate) === propertyShape(property)
)
}),
)
Expand All @@ -460,15 +480,6 @@ const buildDiscriminatedClass = (
)
return { ...property, values }
})
.map((property) => {
if (!actionAttempt || !['error', 'result'].includes(property.name)) {
return property
}
return {
...property,
description: `${property.description}${property.description === '' ? '' : ' '}Null while the action attempt is pending or when this value does not apply.`,
}
})

const discriminantProperty = commonProperties.find(
({ name }) => name === discriminator,
Expand All @@ -477,8 +488,16 @@ const buildDiscriminatedClass = (
throw new Error(`Cannot generate ${path}: missing ${discriminator}`)
}

const enumName = pascalCase(discriminator)
const enumType = `\\${namespace}\\${className}\\${enumName}`
const inheritedNames = new Set(
(options.inheritedProperties ?? []).map(({ name }) => name),
)
const ownCommonProperties = commonProperties.filter(
({ name }) => !inheritedNames.has(name),
)

const enumType =
options.discriminantEnumType ??
`\\${namespace}\\${className}\\${pascalCase(discriminator)}`
const factory: ResourceFactory = {
discriminant: discriminator,
enumType,
Expand All @@ -491,44 +510,78 @@ const buildDiscriminatedClass = (
const built = buildClass(
className,
namespace,
commonProperties,
ownCommonProperties,
path,
depth,
{
...docs,
description: `${docs.description}${docs.description === '' ? '' : ' '}Known ${discriminator} values use subclasses; unknown values use this base class and retain their raw discriminator.`,
},
{ factory },
{
factory,
...(options.extendsName == null
? {}
: { extendsName: options.extendsName }),
...(options.inheritedProperties == null
? {}
: { inheritedProperties: options.inheritedProperties }),
},
)
const base = built.declaration
if (base.kind !== 'class') throw new Error(`Cannot generate ${path}`)
const baseName = `\\${namespace}\\${className}`
const variantInheritedProperties = [
...(options.inheritedProperties ?? []),
...base.properties,
]
for (const { variant, value } of variantInfo) {
const ownProperties = variant.properties
.filter(({ name }) => !commonNames.has(name))
.map((property) => {
if (!actionAttempt || property.name !== 'result') return property
return {
...property,
description: `${property.description}${property.description === '' ? '' : ' '}Null while the action attempt is pending or when this value does not apply.`,
}
})
const variantDocs = {
description: variant.description,
isDeprecated: false,
deprecationMessage: '',
}
const statusVariants =
(options.isActionAttempt ?? false)
? expandActionAttemptByStatus(variant)
: undefined
if (statusVariants != null) {
built.nestedDeclarations.push(
buildDiscriminatedClass(
pascalCase(value),
`${namespace}\\${className}`,
statusVariants,
actionAttemptStatusName,
`${path}.${value}`,
depth + 1,
variantDocs,
{
extendsName: baseName,
inheritedProperties: variantInheritedProperties,
...(commonNames.has(actionAttemptStatusName)
? {
discriminantEnumType: `\\${namespace}\\${className}\\${pascalCase(actionAttemptStatusName)}`,
}
: {}),
},
),
)
continue
}
const ownProperties = variant.properties.filter(
({ name }) => !commonNames.has(name),
)
built.nestedDeclarations.push(
buildClass(
pascalCase(value),
`${namespace}\\${className}`,
ownProperties,
`${path}.${value}`,
depth + 1,
{
description: variant.description,
isDeprecated: false,
deprecationMessage: '',
},
variantDocs,
{
isFinal: true,
extendsName: baseName,
inheritedProperties: base.properties,
inheritedProperties: variantInheritedProperties,
},
),
)
Expand All @@ -537,6 +590,49 @@ const buildDiscriminatedClass = (
return built
}

const actionAttemptStatusName = 'status'

const expandActionAttemptByStatus = (
variant: VariantInput,
): VariantInput[] | undefined => {
const statusProperty = variant.properties.find(
(property): property is EnumProperty =>
property.name === actionAttemptStatusName && property.format === 'enum',
)
if (statusProperty == null) return undefined

return statusProperty.values.map(({ name }) => {
const status = name as ActionAttemptStatus
return {
description: variant.description,
properties: variant.properties.map((property): Property => {
if (property === statusProperty) {
return {
...statusProperty,
values: statusProperty.values.filter(
(value) => value.name === status,
),
}
}
const { actionAttemptStatuses } = property
if (actionAttemptStatuses == null) return property
if (actionAttemptStatuses.includes(status)) return property
const nullRenderedProperty: NullRenderedProperty = {
...property,
isNullable: false,
renderAsNull: true,
}
return nullRenderedProperty
}),
}
})
}

type NullRenderedProperty = Property & { renderAsNull: true }

const isRenderedAsNull = (property: Property): boolean =>
(property as Partial<NullRenderedProperty>).renderAsNull === true

const buildEnum = (
name: string,
namespace: string,
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 @@ -33,7 +33,7 @@
"packageManager": "npm@11.19.0",
"devDependencies": {
"@prettier/plugin-php": "^0.25.0",
"@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",
Expand Down
12 changes: 10 additions & 2 deletions src/ActionAttemptFailedError.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,19 @@ class ActionAttemptFailedError extends ActionAttemptError

public function __construct(ActionAttempt $actionAttempt)
{
$error = get_object_vars($actionAttempt)["error"] ?? null;
$message = null;
$type = null;
if (is_object($error)) {
$errorProperties = get_object_vars($error);
$message = $errorProperties["message"] ?? null;
$type = $errorProperties["type"] ?? null;
}
parent::__construct(
$actionAttempt->error->message ?? "Action attempt failed",
is_string($message) ? $message : "Action attempt failed",
$actionAttempt,
);
$this->errorCode = $actionAttempt->error->type ?? "unknown_error";
$this->errorCode = is_string($type) ? $type : "unknown_error";
}

/**
Expand Down
Loading
Loading