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
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,14 @@ $webhook = new Seam\SeamWebhook($webhook_secret);

try {
$event = $webhook->verify($request_body, $request_headers);
print $event->event_type;

print match (true) {
$event instanceof Seam\Resources\Event\AccessCodeCreated
=> "Created access code {$event->access_code_id}",
$event::class === Seam\Resources\Event::class
=> "Unknown event type {$event->event_type}",
default => "Received {$event->event_type}",
};
} catch (Svix\Exception\WebhookVerificationException $error) {
http_response_code(401);
} catch (Seam\InvalidWebhookPayloadError $error) {
Expand All @@ -339,6 +346,38 @@ try {

### Advanced Usage

#### Enum values

Enum-valued response properties are strings, so they work with ordinary string
comparisons and remain forward-compatible when the API adds a value:

```php
if ($action_attempt->status === "pending") {
// The action is still running.
}
```

The SDK also generates backed enums for autocomplete, discovery of known
values, and optional validation. Use the enum's `value` when comparing, or
`tryFrom()` to convert a response value:

```php
use Seam\Resources\ActionAttempt\Status;
use Seam\Resources\Event\EventType;

if ($action_attempt->status === Status::PENDING->value) {
// The action is still running.
}

$status = Status::tryFrom($action_attempt->status);
$event_type = EventType::tryFrom($event->event_type);
```

`tryFrom()` returns `null` for a value introduced after the installed SDK was
released; the original response property still contains the raw string. Enum
properties also reference their companion enum in PHPDoc for IDE and static
analysis hints.

#### Setting the endpoint

The endpoint may be set with the `SEAM_ENDPOINT` environment variable, or
Expand Down
40 changes: 39 additions & 1 deletion codegen/layouts/resource.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,35 @@ namespace {{namespace}} {
{{#if (hasPhpDoc this)}}
{{{resourcePhpDoc this}}}
{{/if}}
class {{className}}
{{#if isFinal}}final {{/if}}class {{className}}{{#if extendsName}} extends {{extendsName}}{{/if}}
{
public static function from_json(mixed $json): {{className}}|null
{
if (!$json) {
return null;
}
{{#if factory}}
$discriminant = is_string($json->{{factory.discriminant}} ?? null)
? {{factory.enumType}}::tryFrom($json->{{factory.discriminant}})
: null;

return match ($discriminant) {
{{#each factory.variants}}
{{enumCase}} => {{className}}::from_json($json),
{{/each}}
default => new self(
{{#each fromJsonProps}}
{{{this}}}
{{/each}}
),
};
{{else}}
return new self(
{{#each fromJsonProps}}
{{{this}}}
{{/each}}
);
{{/if}}
}

public function __construct(
Expand All @@ -28,9 +45,30 @@ namespace {{namespace}} {
{{{declaration}}}
{{/each}}
) {
{{#if parentArgs}}
parent::__construct(
{{#each parentArgs}}
{{{this}}}
{{/each}}
);
{{/if}}
}
}

{{/each}}
{{#each enums}}
enum {{enumName}}: string
{
{{#each cases}}
{{#if description}}
/**
* {{description}}
*/
{{/if}}
case {{name}} = {{{value}}};
{{/each}}
}

{{/each}}
}

Expand Down
122 changes: 75 additions & 47 deletions codegen/lib/layouts/resource.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
// Builds the template context for resource files (src/Resources/{Name}.php):
// the resource class and the nested classes for its object properties, grouped
// into one braced namespace block per namespace. Each class contributes its
// from_json body lines and constructor parameter lines.
//
// Whether a property is required is carried on isOptional: an optional one
// gets a null default, a required one does not. Constructor parameters are
// emitted required first, since PHP deprecates an optional parameter
// declared before a required one.
// Builds the template context for generated resource files.

import type {
ResourceClassProperty,
ResourceClassSchema,
ResourceEnumSchema,
ResourceSchema,
} from '../resource-model.js'

Expand All @@ -19,8 +12,18 @@ export interface ClassLayoutContext {
description: string
isDeprecated: boolean
deprecationMessage: string
isFinal: boolean
extendsName: string
factory?: FactoryLayoutContext
fromJsonProps: string[]
constructorParams: ConstructorParamLayoutContext[]
parentArgs: string[]
}

export interface FactoryLayoutContext {
discriminant: string
enumType: string
variants: Array<{ enumCase: string; className: string }>
}

export interface ConstructorParamLayoutContext {
Expand All @@ -31,9 +34,15 @@ export interface ConstructorParamLayoutContext {
deprecationMessage: string
}

export interface EnumLayoutContext {
enumName: string
cases: Array<{ name: string; value: string; description: string }>
}

export interface NamespaceLayoutContext {
namespace: string
classes: ClassLayoutContext[]
enums: EnumLayoutContext[]
}

export interface ResourceLayoutContext {
Expand All @@ -60,38 +69,40 @@ const generateFromJsonProp = (property: ResourceClassProperty): string => {

const generateConstructorParam = (
property: ResourceClassProperty,
promote: boolean,
): ConstructorParamLayoutContext => {
let declaration: string
let type: string
let phpDocType = ''
// Resource decoding remains tolerant of sparse response envelopes. Optional
// properties can also be omitted when constructing a resource directly;
// nullable properties retain the same null-safe representation.
const defaultValue = property.isOptional ? ' = null' : ''

switch (property.kind) {
case 'objectReference':
declaration = `public ${property.referenceName}|null $${property.name}${defaultValue},`
type = `${property.referenceName}|null`
break

case 'listReference':
declaration = `public array${property.isOptional ? '|null' : ''} $${property.name}${defaultValue},`
type = `array${property.isOptional ? '|null' : ''}`
phpDocType = `list<${property.referenceName}>${property.isOptional ? '|null' : ''}`
break

case 'record':
type = `${property.phpType}|null`
phpDocType = `${property.phpDocType}|null`
declaration = `public ${property.phpType}|null $${property.name}${defaultValue},`
break

case 'value': {
const { phpType } = property
const nullSuffix = phpType === 'mixed' ? '' : '|null'
declaration = `public ${phpType}${nullSuffix} $${property.name}${defaultValue},`
const nullSuffix = property.phpType === 'mixed' ? '' : '|null'
type = `${property.phpType}${nullSuffix}`
phpDocType =
property.phpDocType === '' || property.phpDocType === property.phpType
? ''
: `${property.phpDocType}|null`
break
}
}

return {
declaration,
declaration: `${promote ? 'public ' : ''}${type} $${property.name}${defaultValue},`,
phpDocType,
description: property.description,
isDeprecated: property.isDeprecated,
Expand All @@ -102,52 +113,69 @@ const generateConstructorParam = (
const getClassLayoutContext = (
schema: ResourceClassSchema,
): ClassLayoutContext => {
const sorted = [...schema.properties].sort((a, b) =>
a.name.localeCompare(b.name),
const inheritedNames = new Set(
schema.inheritedProperties.map(({ name }) => name),
)

const parameterOrder = sortRequiredFirst(sorted)
const properties = sortRequiredFirst([
...schema.inheritedProperties,
...schema.properties,
])

return {
className: schema.name,
description: schema.description,
isDeprecated: schema.isDeprecated,
deprecationMessage: schema.deprecationMessage,
fromJsonProps: sorted.map(generateFromJsonProp),
constructorParams: parameterOrder.map(generateConstructorParam),
isFinal: schema.isFinal,
extendsName: schema.extendsName,
...(schema.factory == null ? {} : { factory: schema.factory }),
fromJsonProps: properties.map(generateFromJsonProp),
constructorParams: properties.map((property) =>
generateConstructorParam(property, !inheritedNames.has(property.name)),
),
parentArgs: schema.inheritedProperties.map(
({ name }) => `${name}: $${name},`,
),
}
}

const getEnumLayoutContext = (
schema: ResourceEnumSchema,
): EnumLayoutContext => ({
enumName: schema.name,
cases: schema.cases.map((enumCase) => ({
...enumCase,
value: JSON.stringify(enumCase.value),
})),
})

const sortRequiredFirst = (
properties: ResourceClassProperty[],
): ResourceClassProperty[] => [
...properties.filter(({ isOptional }) => !isOptional),
...properties.filter(({ isOptional }) => isOptional),
]
): ResourceClassProperty[] =>
[...properties].sort(
(a, b) =>
Number(a.isOptional) - Number(b.isOptional) ||
a.name.localeCompare(b.name),
)

export const setResourceLayoutContext = (
resource: ResourceSchema,
): ResourceLayoutContext => {
// First appearance order, so the resource class leads the file and every
// owning namespace precedes the namespaces nested inside it.
const namespaces = new Map<string, ClassLayoutContext[]>()

for (const schema of resource.classes) {
const classes = namespaces.get(schema.namespace)
const context = getClassLayoutContext(schema)
const namespaces = new Map<string, NamespaceLayoutContext>()

if (classes == null) {
namespaces.set(schema.namespace, [context])
continue
for (const declaration of resource.declarations) {
let context = namespaces.get(declaration.namespace)
if (context == null) {
context = { namespace: declaration.namespace, classes: [], enums: [] }
namespaces.set(declaration.namespace, context)
}

classes.push(context)
if (declaration.kind === 'class') {
context.classes.push(getClassLayoutContext(declaration))
} else {
context.enums.push(getEnumLayoutContext(declaration))
}
}

return {
namespaces: [...namespaces.entries()].map(([namespace, classes]) => ({
namespace,
classes,
})),
}
return { namespaces: [...namespaces.values()] }
}
27 changes: 26 additions & 1 deletion codegen/lib/map-php-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ type RecordValueType = NonNullable<
Extract<Parameter, { format: 'record' }>['valueTypes']
>[number]

export const getPhpType = (schema: Parameter | Property): string => {
export const getPhpType = (
schema: Parameter | Property,
enumType = 'string',
): string => {
if (schema.format === 'enum') return enumType
if (schema.format === 'record' && !('resourceType' in schema)) {
return 'array|\\stdClass'
}
Expand All @@ -34,6 +38,10 @@ export const getPhpType = (schema: Parameter | Property): string => {
}

export const getPhpDocType = (schema: Parameter | Property): string => {
if (schema.format === 'list') {
return `list<${getListItemPhpType(schema)}>`
}

if (schema.format !== 'record' || 'resourceType' in schema) {
return getPhpType(schema)
}
Expand All @@ -45,6 +53,23 @@ export const getPhpDocType = (schema: Parameter | Property): string => {
return `array<string, ${types.length === 0 ? 'mixed' : types.join('|')}>|\\stdClass`
}

const getListItemPhpType = (
schema: Extract<Parameter | Property, { format: 'list' }>,
): string => {
switch (schema.itemFormat) {
case 'number':
return 'isItemInt' in schema && schema.isItemInt ? 'int' : 'float'
case 'boolean':
return 'bool'
case 'object':
case 'record':
case 'discriminated_object':
return 'array<string, mixed>|\\stdClass'
default:
return 'string'
}
}

const getRecordValuePhpType = (type: RecordValueType): string => {
switch (type) {
case 'string':
Expand Down
Loading
Loading