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
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<?php

namespace Seam\Objects;
namespace Seam\Resources;

{{#each classes}}
class {{className}}
{
public static function from_json(mixed $json): {{className}}|null
Expand All @@ -23,3 +24,5 @@ class {{className}}
) {
}
}

{{/each}}
2 changes: 1 addition & 1 deletion codegen/layouts/seam-client.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
namespace Seam;

{{#each useStatements}}
use Seam\Objects\\{{this}};
use Seam\Resources\\{{this}};
{{/each}}
use Seam\Utils\PackageVersion;

Expand Down
37 changes: 25 additions & 12 deletions codegen/lib/layouts/object.ts → codegen/lib/layouts/resource.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
// Builds the template context for resource object files (src/Objects/{Name}.php):
// the from_json body lines and the constructor parameter lines.
// Builds the template context for resource files (src/Resources/{Name}.php):
// the resource class followed by the local classes for its object properties.
// Each class contributes its from_json body lines and constructor parameter
// lines.
//
// The blueprint does not track which resource properties are required, so
// every property is optional: from_json falls back to null for missing values
// and the constructor parameters are nullable.

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

export interface ObjectLayoutContext {
export interface ClassLayoutContext {
className: string
fromJsonProps: string[]
constructorParams: string[]
}

const generateFromJsonProp = (property: ResourceObjectProperty): string => {
export interface ResourceLayoutContext {
classes: ClassLayoutContext[]
}

const generateFromJsonProp = (property: ResourceClassProperty): string => {
const { name } = property

switch (property.kind) {
Expand All @@ -31,9 +38,7 @@ const generateFromJsonProp = (property: ResourceObjectProperty): string => {
}
}

const generateConstructorParam = (
property: ResourceObjectProperty,
): string => {
const generateConstructorParam = (property: ResourceClassProperty): string => {
switch (property.kind) {
case 'objectReference':
return `public ${property.referenceName}|null $${property.name},`
Expand All @@ -49,9 +54,9 @@ const generateConstructorParam = (
}
}

export const setObjectLayoutContext = (
schema: ResourceObjectSchema,
): ObjectLayoutContext => {
const getClassLayoutContext = (
schema: ResourceClassSchema,
): ClassLayoutContext => {
const sorted = [...schema.properties].sort((a, b) =>
a.name.localeCompare(b.name),
)
Expand All @@ -62,3 +67,11 @@ export const setObjectLayoutContext = (
constructorParams: sorted.map(generateConstructorParam),
}
}

export const setResourceLayoutContext = (
resource: ResourceSchema,
): ResourceLayoutContext => ({
classes: [resource.resourceClass, ...resource.localClasses].map(
getClassLayoutContext,
),
})
4 changes: 2 additions & 2 deletions codegen/lib/layouts/seam-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@ const getMethodLayoutContext = (

export const setSeamClientLayoutContext = (
clients: PhpClient[],
baseResourceObjectNames: string[],
resourceNames: string[],
): SeamClientLayoutContext => ({
useStatements: baseResourceObjectNames,
useStatements: resourceNames,
parentClients: clients
.filter((c) => c.isParentClient)
.map((c) => ({ clientName: c.clientName, namespace: c.namespace })),
Expand Down
102 changes: 66 additions & 36 deletions codegen/lib/resource-model.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
// Builds the resource object class model for src/Objects from the blueprint.
// Builds the resource class model for src/Resources from the blueprint.
//
// Each blueprint resource becomes a PHP class. Nested object properties and
// lists of objects are split into their own classes named after the base
// resource and the property, e.g. the device battery property becomes
// DeviceBattery. Discriminated unions (events, action attempts, and
// Each blueprint resource becomes a PHP class in its own file. Nested object
// properties and lists of objects are split into their own classes, named
// after the base resource and the property, e.g. the device battery property
// becomes DeviceBattery. Those classes only exist to type a resource
// property, so they are emitted as local classes in the file of the resource
// that introduced them. Discriminated unions (events, action attempts, and
// discriminated object lists) are flattened into a single class with the
// union of the variant properties.

Expand All @@ -12,24 +14,28 @@ import { pascalCase } from 'change-case'

import { getPhpType } from './map-php-type.js'

export type ResourceObjectProperty =
export type ResourceClassProperty =
| { name: string; kind: 'value'; phpType: string }
| { name: string; kind: 'objectReference'; referenceName: string }
| { name: string; kind: 'listReference'; referenceName: string }

export interface ResourceObjectSchema {
export interface ResourceClassSchema {
name: string
properties: ResourceObjectProperty[]
properties: ResourceClassProperty[]
}

export interface ResourceObjectModel {
baseResourceNames: string[]
schemas: ResourceObjectSchema[]
export interface ResourceSchema {
name: string
resourceClass: ResourceClassSchema
localClasses: ResourceClassSchema[]
}

export interface ResourceModel {
resourceNames: string[]
resources: ResourceSchema[]
}

export const createResourceObjectModel = (
blueprint: Blueprint,
): ResourceObjectModel => {
export const createResourceModel = (blueprint: Blueprint): ResourceModel => {
const baseResources = new Map<string, Property[]>()

for (const resource of blueprint.resources) {
Expand Down Expand Up @@ -57,50 +63,74 @@ export const createResourceObjectModel = (
)
}

const schemas = new Map<string, ResourceObjectSchema>()
const classes = new Map<string, ResourceClassSchema>()
const localClassNames = new Map<string, string[]>()

const addSchema = (
let currentResourceName = ''

const addClass = (
name: string,
properties: Property[],
baseName: string,
): void => {
if (schemas.has(name)) return
const schema: ResourceObjectSchema = { name, properties: [] }
schemas.set(name, schema)
if (classes.has(name)) return
const schema: ResourceClassSchema = { name, properties: [] }
classes.set(name, schema)
if (name !== currentResourceName) {
localClassNames.get(currentResourceName)?.push(name)
}
schema.properties = properties.map((property) =>
createResourceObjectProperty(property, baseName, addSchema),
createResourceClassProperty(property, baseName, addClass),
)
}

const baseResourceTypes = [...baseResources.keys()].sort()
for (const resourceType of baseResourceTypes) {
addSchema(
pascalCase(resourceType),
baseResources.get(resourceType) ?? [],
resourceType,
)
}
const resources = baseResourceTypes.map((resourceType) => {
const name = pascalCase(resourceType)
currentResourceName = name
localClassNames.set(name, [])
addClass(name, baseResources.get(resourceType) ?? [], resourceType)

const resourceClass = classes.get(name)
if (resourceClass == null) {
throw new Error(
`Missing class for resource ${resourceType}: ${name} is already used by a property class of another resource`,
)
}

return {
name,
resourceClass,
localClasses: (localClassNames.get(name) ?? [])
.map((localClassName) => {
const localClass = classes.get(localClassName)
if (localClass == null) {
throw new Error(`Missing local class ${localClassName}`)
}
return localClass
})
.sort((a, b) => a.name.localeCompare(b.name)),
}
})

return {
baseResourceNames: baseResourceTypes.map((resourceType) =>
pascalCase(resourceType),
),
schemas: [...schemas.values()],
resourceNames: resources.map((resource) => resource.name),
resources,
}
}

const createResourceObjectProperty = (
const createResourceClassProperty = (
property: Property,
baseName: string,
addSchema: (name: string, properties: Property[], baseName: string) => void,
): ResourceObjectProperty => {
addClass: (name: string, properties: Property[], baseName: string) => void,
): ResourceClassProperty => {
const referenceName = pascalCase(`${baseName}_${property.name}`)

if (property.format === 'object') {
const { properties } = property

if (properties.length > 0) {
addSchema(referenceName, properties, baseName)
addClass(referenceName, properties, baseName)
return { name: property.name, kind: 'objectReference', referenceName }
}
}
Expand All @@ -116,7 +146,7 @@ const createResourceObjectProperty = (
: []

if (itemProperties.length > 0) {
addSchema(referenceName, itemProperties, baseName)
addClass(referenceName, itemProperties, baseName)
return { name: property.name, kind: 'listReference', referenceName }
}
}
Expand Down
31 changes: 16 additions & 15 deletions codegen/lib/routes.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
// The Metalsmith plugin that generates the PHP SDK source files.
//
// The blueprint from @seamapi/blueprint is the only input: it drives the
// resource object classes written to src/Objects, and the resource client
// resource classes written to src/Resources, and the resource client
// classes serialized into src/SeamClient.php.

import type { Blueprint, Endpoint } from '@seamapi/blueprint'
import { pascalCase } from 'change-case'
import type Metalsmith from 'metalsmith'

import type { PhpClient, PhpClientMethod } from './class-model.js'
import { setObjectLayoutContext } from './layouts/object.js'
import { setResourceLayoutContext } from './layouts/resource.js'
import { setSeamClientLayoutContext } from './layouts/seam-client.js'
import { getPhpType } from './map-php-type.js'
import { createResourceObjectModel } from './resource-model.js'
import { createResourceModel } from './resource-model.js'

interface Metadata {
blueprint: Blueprint
}

const objectsPath = 'src/Objects'
const resourcesPath = 'src/Resources'
const seamClientPath = 'src/SeamClient.php'

export const routes = (
Expand All @@ -28,15 +28,16 @@ export const routes = (
const metadata = metalsmith.metadata() as Metadata
const { blueprint } = metadata

// Resource object classes, one file per (deeply extracted) schema. The base
// resource names drive the SeamClient use statements.
const { baseResourceNames, schemas } = createResourceObjectModel(blueprint)
// Resource classes, one file per resource holding the resource class and
// the local classes for its object properties. The resource names drive the
// SeamClient use statements.
const { resourceNames, resources } = createResourceModel(blueprint)

for (const schema of schemas) {
files[`${objectsPath}/${schema.name}.php`] = {
for (const resource of resources) {
files[`${resourcesPath}/${resource.name}.php`] = {
contents: Buffer.from('\n'),
layout: 'object.hbs',
...setObjectLayoutContext(schema),
layout: 'resource.hbs',
...setResourceLayoutContext(resource),
}
}

Expand Down Expand Up @@ -83,7 +84,7 @@ export const routes = (
files[seamClientPath] = {
contents: Buffer.from('\n'),
layout: 'seam-client.hbs',
...setSeamClientLayoutContext([...classMap.values()], baseResourceNames),
...setSeamClientLayoutContext([...classMap.values()], resourceNames),
}
}

Expand All @@ -93,9 +94,9 @@ const createClientMethod = (endpoint: Endpoint): PhpClientMethod => {
const responseKey = response.responseType === 'void' ? '' : response.responseKey

// Batch responses have no single resource type; they deserialize into the
// Batch resource object. A response whose resource type the blueprint
// cannot resolve ('unknown') has no resource object class to deserialize
// into, so the method is generated as returning void.
// Batch resource. A response whose resource type the blueprint cannot
// resolve ('unknown') has no resource class to deserialize into, so the
// method is generated as returning void.
const resourceType =
response.responseType === 'void'
? ''
Expand Down
2 changes: 1 addition & 1 deletion codegen/smith.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { helpers, routes } from './lib/index.js'

const rootDir = dirname(fileURLToPath(import.meta.url))

await Promise.all([deleteAsync(['./src/Objects', './src/SeamClient.php'])])
await Promise.all([deleteAsync(['./src/Resources', './src/SeamClient.php'])])

const partials = await getHandlebarsPartials(`${rootDir}/layouts/partials`)

Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"psr-4": {
"Seam\\": ["src/", "src/Exceptions/"],
"Tests\\": "tests/"
}
},
"classmap": ["src/Resources/"]
},
"authors": [
{
Expand Down
2 changes: 1 addition & 1 deletion src/Exceptions/ActionAttemptError.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace Seam;

use Seam\Objects\ActionAttempt;
use Seam\Resources\ActionAttempt;

class ActionAttemptError extends \Exception
{
Expand Down
2 changes: 1 addition & 1 deletion src/Exceptions/ActionAttemptFailedError.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace Seam;

use Seam\Objects\ActionAttempt;
use Seam\Resources\ActionAttempt;

class ActionAttemptFailedError extends ActionAttemptError
{
Expand Down
2 changes: 1 addition & 1 deletion src/Exceptions/ActionAttemptTimeoutError.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace Seam;

use Seam\Objects\ActionAttempt;
use Seam\Resources\ActionAttempt;

class ActionAttemptTimeoutError extends ActionAttemptError
{
Expand Down
Loading
Loading