Example walkthrough
Let’s build a project that consumes a simplified REST API specification and produces a TypeScript client that interacts with a service that implements that specification. Along the way we will learn about the key concepts needed to generate code using the alloy framework.
The final source code for this sample can be found in our github repo.
Goal
Let’s say we have a super simple OpenAPI-like REST API description that follows the following schema format:
export interface RestApi { name: string; operations: RestApiOperation[]; models: RestApiModel[];}
export interface RestApiOperation { name: string; endpoint: string; verb: "get" | "post"; requestBody?: RestApiModelReference; responseBody?: RestApiModelReference;}
export interface RestApiModelReference { ref: string; array?: boolean;}
export interface RestApiModel { name: string; properties: RestApiModelProperty[];}
export interface RestApiModelProperty { name: string; type: RestApiModel | RestApiModelReference | "string" | "number" | "boolean";}
The goal is to turn a service described using this schema format into a REST client that can make service calls. This requires reflecting on the schema for a given service and turning that into the appropriate source code that can be built into a working client for that service.
Initial steps
First, let’s initialize our alloy project. See the getting started guide for details, but the easiest way is to clone the starter template.
mkdir my-projectcd my-projectnpm init @alloy-jspnpm installpnpm build
Defining our service
Let’s generate a client for a hypothetical pet store API, with the following endpoints:
- list pets
- create a pet
- get a specific pet
And to make things slightly more interesting, let’s say that a pet also has a favorite toy.
We can describe this using our schema format above as follows:
export const api: RestApi = { name: "Petstore", operations: [ { name: "create_pet", verb: "post", endpoint: "/pets", requestBody: { ref: "Pet", }, responseBody: { ref: "Pet", }, }, { name: "list_pets", verb: "get", endpoint: "/pets", responseBody: { ref: "Pet", array: true, }, }, { name: "get_pet", verb: "get", endpoint: "/pets/:id", responseBody: { ref: "Pet", }, }, ], models: [ { name: "Pet", properties: [ { name: "name", type: "string" }, { name: "age", type: "number" }, { name: "favoriteToy", type: { ref: "Toy" } }, ], }, { name: "Toy", properties: [{ name: "name", type: "string" }], }, ],};
Creating our entrypoint
Next, let’s create the entrypoint for our code generator, and see some output
so we can start iterating. Create src/index.tsx
as follows:
import { For, Output, render, writeOutput } from "@alloy-js/core";import * as ts from "@alloy-js/typescript";import { api } from "./schema.js";
const output = render( <Output> <ts.SourceFile path="models.ts"> console.log("hello world!"); </ts.SourceFile> </Output>);
writeOutput(output, "./alloy-output");
We begin by importing some things we need from @alloy-js/core
and
@alloy-js/typescript
. We choose to import language components under a
namespace to help clarify which components are TypeScript specific but this is
not a requirement.
Now if we build and run dist/src/index.js
, we should find
alloy-output/models.ts
with hello world in it. Neat, but not very useful.
Let’s fix that.
Building a package
We want our client to be a package we can package with npm and distribute to our users. We’re going to need a package.json for that. We’ll also need a tsconfig.json since we’re emitting TypeScript code that needs to be built. We could create these manually, but alloy comes with PackageDirectory component that will generate these for us. And, as we’ll see, it will also keep these up to date with the structure of our project and its exports.
So let’s use that component, and declare some source files inside of it. We’ll
need models.ts
which we already have. We’ll also need client.ts
. And let’s
also create an index.ts
that exports both of these and use that as our package
main. For this, we can use the
BarrelFile component.
Update our output to be the following:
const output = render( <Output> <ts.PackageDirectory name={`${api.name}-client`} version="1.0.0"> <ts.SourceFile path="models.ts"> </ts.SourceFile> <ts.SourceFile path="client.ts"> </ts.SourceFile> <ts.BarrelFile export="." /> </ts.PackageDirectory> </Output>,);
Note how we provide the export
prop to the BarrelFile component - this ensures that
this module is exported from the package under the given path.
Now if we build and run dist/src/index.js
, we’ll find we have emitted a
working package under alloy-output
:
Directoryalloy-output
- client.ts empty for now
- index.ts exporting the other ts files
- models.ts empty for now
- package.json with exports set appropriately
- tsconfig.json with some sane defaults
index.js
is exporting some empty files though, and we’ll get to that, but
first we need a little context.
Adding useful context
Before writing more of our emitter, one observation we can make is that the schema for the service we are emitting is going to be needed in a lot of places. Typically you would probably just import the schema itself from a ts file somewhere and be done, but there’s another option: storing it in context.
Context is useful because we won’t need to pass the current API via props to all components which need it. Instead, the current API can be provided to child components by way of a Context Provider. Context is also useful in that the same context can have different values in different parts of your program. So by putting the schema in Context, we gain some additional flexibility down the line in case we want to support generating clients for multiple services within a single package.
To create context, create src/context/api.ts
with the following contents:
import { createContext, useContext } from "@alloy-js/core";import { RestApi, RestApiModel, RestApiModelReference } from "../schema.js";
// context interfaceinterface ApiContext { schema: RestApi; resolveReference: (ref: RestApiModelReference) => RestApiModel | undefined;}
// context variableexport const ApiContext = createContext<ApiContext>();
// context accessorexport function useApi(): ApiContext { return useContext(ApiContext)!;}
export function createApiContext(schema: RestApi): ApiContext { return { schema, resolveReference(node) { const model = schema.models.find((v) => v.name === node.ref);
if (!model) { throw new Error(`Unresolved reference ${node.ref}`); }
return model; }, };}
As described in Basic concepts, there are three parts to our context:
interface ApiContext
- the context interface, the type components see when they get the context.const ApiContext
- the context variable, which identifies the context and gives access to the Provider component for setting the context.function useApi
- the context accessor, which returns the current context value.
For our purposes, one of the main things we’ll need from context is resolving references to schemas, so we add an API for that on the context itself.
With API context established, we can import it into src/index.tsx
along with
the api schema itself from src/schema.ts
and provide it to all child
components. We do this by using ApiContext.Provider
as follows:
const output = render( <Output> <ApiContext.Provider value={createApiContext(api)}> <ts.PackageDirectory name={`${api.name}-client`} version="1.0.0"> <ts.SourceFile path="models.ts"> </ts.SourceFile> <ts.SourceFile path="client.ts"> </ts.SourceFile> <ts.BarrelFile export="." /> </ts.PackageDirectory> </ApiContext.Provider> </Output>,);
Emitting models
For TypeScript, we want to emit the models as TypeScript interfaces that describe plain old JavaScript objects. We could place these interfaces directly within the models.ts file, but we’re going to need some logic to build them. Encapsulating that logic inside of a component will keep our code clean and give us a lot of flexibility down the line.
So let’s create both src/components/Model.tsx
and
src/components/ModelProperty.tsx
.
The Model component
import { For, refkey } from "@alloy-js/core";import * as ts from "@alloy-js/typescript";
import { RestApiModel } from "../schema.js";import { ModelProperty } from "./ModelProperty.jsx";
interface ModelProps { model: RestApiModel;}
export function Model(props: ModelProps) { return ( <ts.InterfaceDeclaration export name={props.model.name} refkey={refkey(props.model)} > <For each={props.model.properties} comma hardline enderPunctuation> {(prop) => <ModelProperty property={prop} />} </For> </ts.InterfaceDeclaration> );}
This creates the model properties using a component we’ll cover in the next
section and adds them into an interface declaration. This component introduces
two new concepts: The For
component and refkey
.
For takes an iterable thing and maps it
using the provided callback. Various props like comma
, hardline
, and
enderPunctuation
control what content goes between each element and at the end
of all the elements. This is the main way we format lists of things.
A refkey is a unique identifier for some symbol, and is used to create references to that symbol. Since we are going to be referencing our model interfaces from other model interfaces and also the client, we give them a refkey.
The ModelProperty component
import { Children, refkey } from "@alloy-js/core";import * as ts from "@alloy-js/typescript";import { useApi } from "../context/api.js";import { RestApiModelProperty } from "../schema.js";import { Model } from "./Model.jsx";
interface ModelPropertyProps { property: RestApiModelProperty;}
export function ModelProperty(props: ModelPropertyProps) { let memberType: Children;
const apiType = props.property.type;
if (typeof apiType === "object") { if ("ref" in apiType) { const apiContext = useApi(); const model = apiContext.resolveReference(apiType); memberType = refkey(model); } else { memberType = <Model model={apiType} />; } } else { memberType = apiType; }
return <ts.InterfaceMember name={props.property.name} type={memberType} />;}
To explain the various parts:
- We start by importing the pieces we need, including the context accessor we defined in the previous section.
- We create
ModelProps
to define the props passed to theModelProperty
component. This is just the schema for the property. - We define the ModelProperty component which:
- Unpacks the type, handling the case of an API type reference, an inline anonymous type, or a primitive type. Luckily, the primitive type values correspond to the TypeScript syntax for that primitive type, so we can just use those directly.
- Defines the interface member using the
InterfaceMember
component from
@alloy-js/typescript
.
With these two components done, we can update our index.tsx
to make use of these new
components. Using For
, we can emit each of our model declarations.
const output = render( <Output> <ApiContext.Provider value={createApiContext(api)}> <ts.PackageDirectory name={`${api.name}-client`} version="1.0.0"> <ts.SourceFile path="models.ts"> <For each={api.models}>{(model) => <Model model={model} />}</For> </ts.SourceFile> <ts.SourceFile path="client.ts"> </ts.SourceFile> <ts.BarrelFile export="." /> </ts.PackageDirectory> </ApiContext.Provider> </Output>,);
Now if we build and run dist/src/index.js
, we get a models.ts
with the
following content:
export interface Pet { name: string; age: number; favoriteToy: Toy;}export interface Toy { name: string;}
Now it’s time for the fun part - the client!
Creating our client class
In order to create the client, we will again want two components: Client
and
ClientMethod
.
The ClientMethod component
This component is the most complex by far, because here is where we need to pull apart the service endpoint schema and map it into method parameters, implementation details, and return type. Rather than paste the whole thing, let’s take it step by step.
1. Imports and setup
import { Block, For, Children, code, refkey } from "@alloy-js/core";import * as ts from "@alloy-js/typescript";import { useApi } from "../context/api.js";import { RestApiOperation } from "../schema.js";
export interface ClientMethodProps { operation: RestApiOperation;}
export function ClientMethod(props: ClientMethodProps) { const apiContext = useApi(); const op = props.operation; // ...
Similar to all previous components, we import the necessary pieces, declare our component’s props, and get access to the current API spec inside the component.
2. Determine the method’s parameters
const parameters: Record<string, ts.ParameterDescriptor> = {};
const endpointParam = op.endpoint.match(/:(w+)$/)?.[1];if (endpointParam) { parameters[endpointParam] = { type: "string", refkey: refkey(op, endpointParam), };}
if (op.requestBody) { parameters["body"] = { type: refkey(apiContext.resolveReference(op.requestBody)), refkey: refkey(op, "requestBody"), };}
We construct the parameters expected by the ClassMethod component by extracting any parameters defined in the endpoint and by the request body. We create refkeys for these parameters so we can refer to them easily later.
3. Determine the method’s return type
let returnType: Children;if (op.responseBody === undefined) { returnType = "Promise<void>";} else { const responseModel = apiContext.resolveReference(op.responseBody);
const reference: Children = [refkey(responseModel)]; if (op.responseBody.array) { reference.push("[]"); }
returnType = code`Promise<${reference}>`;}
We construct the return type of the class method based on the responseBody
schema property. If it’s not present, we simply return Promise<void>
.
Otherwise, we resolve the reference to obtain the model schema it returns. We
reference in our output source code by placing refkey(responseModel)
in the
output. Note how this will be the same refkey
as we defined in our
models.ts
, so alloy will take care of importing it for us. Lastly, we wrap the
reference in a Promise.
4. Determine the endpoint to call
let endpoint: Children;if (endpointParam) { endpoint = <> "{op.endpoint.slice(0, -endpointParam.length - 1)}" + {refkey(op, endpointParam)} </>;} else { endpoint = <>"{op.endpoint}"</>;}
The endpoint to call may depend on a path parameter passed into the method, so we need to construct that strhing. When the endpoint has a parameter defined, we slice off the parameter definition in the endpoint url and concat the value passed in. We again refer to the parameter with a refkey, which we defined in step 2.
5. Determine the parameters to fetch
const options = op.verb === "post" && ( <ts.ObjectExpression> <ts.CommaList> <ts.ObjectProperty name="method" jsValue={"POST"} /> <ts.ObjectProperty name="body"> JSON.stringify({refkey(op, "requestBody")}) </ts.ObjectProperty> </ts.CommaList> </ts.ObjectExpression>);
When we are doing a POST, we need to tell fetch
to use that verb and provide
the body. We can do this by constructing an object expression using the
ObjectExpression
component. The body
property is set to a snippet of source code that JSON
stringifies the requestBody
parameter, which we defined in step 2.
6. Assembling the method body
Finally, we can pull everything together into the method body:
return ( <ts.ClassMethod async name={op.name} parameters={parameters} returnType={returnType} > {code` const response = await ${( <ts.FunctionCallExpression target="fetch" args={[endpoint, options]} /> )};
if (!response.ok) { throw new Error("Request failed: " + response.status); }
return response.json() as ${returnType}; `} </ts.ClassMethod>);
The class method is defined as async, given a name based on what’s in the API spec, and all the parts we assembled previously are slotted into the appropriate place.
The Client component
The Client component is relatively straight forward:
import { refkey } from "@alloy-js/core";import * as ts from "@alloy-js/typescript";import { useApi } from "../context/api.js";import { ClientMethod } from "./ClientMethod.jsx";
export function Client() { const schema = useApi().schema; const name = `${schema.name}Client`;
return <ts.ClassDeclaration name={name} export refkey={refkey(schema)}> <For each={schema.operations} doubleHardline> {(op) => <ClientMethod operation={op} />} </For> </ts.ClassDeclaration>;}
We join together each of the methods using For
. The class itself gets a name
of the service concatenated with Client
. It is exported from the module. It is
also given a refkey for good measure, although we don’t use it yet.
With this, we can drop the client into our output by importing the Client
component and updating our output like the following:
<Output> <ApiContext.Provider value={createApiContext(api)}> <ts.PackageDirectory name={`${api.name}-client`} version="1.0.0"> <ts.SourceFile path="models.ts"> {modelDecls} </ts.SourceFile> <ts.SourceFile path="client.ts"> <Client /> </ts.SourceFile> <ts.BarrelFile export="." /> </ts.PackageDirectory> </ApiContext.Provider></Output>
Adding a name policy
If we generated code at this point, we’d see something amiss - snake case in our
TypeScript! Hissss. The spec author has poor taste, but there’s no accounting
for that. Thankfully we can fix this fairly easily by providing a naming policy.
The naming policy is used by all the built-in Alloy components to recase any
names provided by a name
prop.
In src/index.tsx
, we can create a naming policy and pass it as a prop to the Output
component like so:
const namePolicy = ts.createTSNamePolicy();
const output = render( <Output namePolicy={namePolicy}> <ApiContext.Provider value={createApiContext(api)}> <ts.PackageDirectory name={`${api.name}-client`} version="1.0.0"> <ts.SourceFile path="models.ts"> {modelDecls} </ts.SourceFile> <ts.SourceFile path="client.ts"> <Client /> </ts.SourceFile> <ts.BarrelFile export="." /> </ts.PackageDirectory> </ApiContext.Provider> </Output>,);
And with that, we check our output, and we have a working client!
import { Pet } from "./models.js";
export default class PetstoreClient { async createPet(body: Pet): Promise<Pet> { const response = await fetch("/pets", { method: "POST", body: JSON.stringify(body), });
if (!response.ok) { throw new Error("Request failed: " + response.status); }
return response.json() as Promise<Pet>; } async listPets(): Promise<Pet[]> { const response = await fetch("/pets");
if (!response.ok) { throw new Error("Request failed: " + response.status); }
return response.json() as Promise<Pet[]>; } async getPet(id: string): Promise<Pet> { const response = await fetch("/pets/" + id);
if (!response.ok) { throw new Error("Request failed: " + response.status); }
return response.json() as Promise<Pet>; }}