Skip to content
Draft
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
32 changes: 16 additions & 16 deletions conformance/RFC.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,17 @@ start, and knows nothing about the target's language or toolchain.

One OpenAPI document, written for the cases, with a fixed set of 9 methods:

| Method | Route | Covers |
| -------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `health` | `GET /health` | root method, bearer `token` header, no parameters |
| `projects.create` | `POST /orgs/{org_id}/projects` | SDK variable (`org_id`), object body, `date-time`, `date`, `int64`, `byte`, 201 |
| `projects.get` | `GET /orgs/{org_id}/projects/{project_id}` | path parameters, declared errors (404, `4XX`), undeclared status, unknown response properties |
| `projects.update` | `PATCH /orgs/{org_id}/projects/{project_id}` | path, query (repeated and special-character names) and header parameters with an object body, body property colliding with a parameter name |
| `projects.delete` | `DELETE /orgs/{org_id}/projects/{project_id}` | 204 response with no body |
| `projects.archive` | `POST /orgs/{org_id}/projects/{project_id}/archive` | method and subgroup with the same name |
| `projects.archive.restore` | `POST /orgs/{org_id}/projects/{project_id}/archive/restore` | subgroup on a method |
| `projects.members.replace` | `PUT /orgs/{org_id}/projects/{project_id}/members` | nested group, array body, two success statuses (200 and 201) with different bodies |
| `dnsRecords.getById` | `POST /zones/{zone_id}/dns-records/{record_id}` | kebab-case group (`dns-records`), camelCase method with acronym (`getByID`), primitives, enums, maps, `nullable`, `allOf`, `oneOf`, `anyOf`, recursive models |
| Method | Route | Covers |
| -------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `health` | `GET /health` | root method, bearer `token` header, no parameters |
| `projects.create` | `POST /orgs/{org_id}/projects` | SDK variable (`org_id`), object body, `date-time`, `date`, `int64`, `byte`, 201 |
| `projects.get` | `GET /orgs/{org_id}/projects/{project_id}` | path parameters, declared errors (404, `4XX`), undeclared status, unknown response properties |
| `projects.update` | `PATCH /orgs/{org_id}/projects/{project_id}` | path, query (repeated and special-character names) and header parameters with an object body, body property colliding with a parameter name |
| `projects.delete` | `DELETE /orgs/{org_id}/projects/{project_id}` | 204 response with no body |
| `projects.archive` | `POST /orgs/{org_id}/projects/{project_id}/archive` | method and subnamespace with the same name |
| `projects.archive.restore` | `POST /orgs/{org_id}/projects/{project_id}/archive/restore` | subnamespace on a method |
| `projects.members.replace` | `PUT /orgs/{org_id}/projects/{project_id}/members` | nested namespace, array body, two success statuses (200 and 201) with different bodies |
| `dnsRecords.getById` | `POST /zones/{zone_id}/dns-records/{record_id}` | kebab-case namespace (`dns-records`), camelCase method with acronym (`getByID`), primitives, enums, maps, `nullable`, `allOf`, `oneOf`, `anyOf`, recursive models |

Every method in the suite API has at least one case.

Expand All @@ -67,7 +67,7 @@ the caller must get. Cases are YAML files, each a list of cases.
- name: an object body is sent in wire names
variables: { orgId: o1 }
call:
group: projects
namespace: projects
method: create
body: { displayName: Demo }
exchange:
Expand All @@ -91,8 +91,8 @@ type Case = {
name: string; // unique across all case files
variables?: Record<string, Value>; // SDK variables (x-fern-sdk-variable), set when the client is created
call: {
group: string; // property path from the root client, e.g. "projects.members"; "" for root
method: string; // method name on that group
namespace: string; // property path from the root client, e.g. "projects.members"; "" for root
method: string; // method name on that namespace
path?: Record<string, Value>; // path parameters, by field name
query?: Record<string, Value>; // query parameters, by field name
headers?: Record<string, Value>; // header parameters, by field name
Expand Down Expand Up @@ -190,7 +190,7 @@ Input, the case's client side plus where to send requests:
"baseUrl": "http://127.0.0.1:52100/",
"token": "conformance-token",
"variables": { "orgId": "o1" },
"call": { "group": "projects", "method": "create", "body": { "displayName": "Demo" } }
"call": { "namespace": "projects", "method": "create", "body": { "displayName": "Demo" } }
}
```

Expand All @@ -205,7 +205,7 @@ Output, with the same id and a `Result`, or `unexpected`:
For each line the program:

1. creates a client from `baseUrl`, `token` and `variables`;
2. calls `call.method` on `call.group` with the inputs in `call`;
2. calls `call.method` on `call.namespace` with the inputs in `call`;
3. maps the return value or error to a `Result`, converting tagged values to
and from the language's types.

Expand Down
47 changes: 26 additions & 21 deletions packages/forge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ to select more than one. For example,

What is rewritten:

- A group path written as an array becomes the names joined by dots
- A namespace path written as an array becomes the names joined by dots
(`["a", "b"]` becomes `"a.b"`), unless a name has a dot in it.
- A skip for only some targets, written as a list, has no native form, since
`x-forge-skip` is for every target: it is left as it is. A skip that is plainly
Expand Down Expand Up @@ -88,7 +88,7 @@ has a key to rewrite is not a minimal edit. The order of its keys is kept.
## Plugins

A plugin generates an SDK, or anything else, from the document: forge parses
it, and hands the plugin its groups, methods, models and types. Plugins are
it, and hands the plugin its namespaces, methods, models and types. Plugins are
WebAssembly components, so they run anywhere forge does, sandboxed: a plugin
reads nothing but the document, and its only output is the files it returns.

Expand Down Expand Up @@ -120,16 +120,16 @@ function ts(type: Type): string {
}
}

export const { generate, generateGroup } = definePlugin({
export const { generate, generateNamespace } = definePlugin({
// Called once, for the whole document.
generate(forge) {
return [{ path: 'README.md', content: `# API ${forge.version}\n` }];
},

// Called once for each group, in parallel.
generateGroup(group) {
const dir = group.path.map((name) => name.camel()).join('/');
const lines = group.models.map((m) => `export type ${m.name.pascal()} = ${ts(m.type)};`);
// Called once for each namespace, in parallel.
generateNamespace(namespace) {
const dir = namespace.path.map((name) => name.camel()).join('/');
const lines = namespace.models.map((m) => `export type ${m.name.pascal()} = ${ts(m.type)};`);
return [{ path: `${dir}/models.ts`, content: lines.join('\n') }];
},
});
Expand All @@ -144,7 +144,7 @@ npx forge openapi.json --target wasm://plugin.wasm --out sdk

`@cloudflare/forge/plugin` is typed throughout. A type is a union you narrow
by `kind`, so a `never` check in a `switch` turns a kind you missed into a
compile error. The same group, method or model is always the same object,
compile error. The same namespace, method or model is always the same object,
so you can compare them with `===` and keep them in a `Map`.

Array types expose `minItems` and `maxItems`; an absent bound is
Expand All @@ -153,18 +153,23 @@ Array types expose `minItems` and `maxItems`; an absent bound is
can handle it explicitly. Other unrecognized string formats remain
`scalar: 'string'`. Forge does not validate request values.

The plugin interface is `forge:plugin@0.3.0`. Rebuild plugins compiled
against `0.2.0` with the updated package before running them with this
version of Forge.
The plugin interface is `forge:plugin@0.4.0`. It renames groups to namespaces,
so rebuild plugins compiled against `0.3.0` with the updated package before
running them with this version of Forge. The WIT intentionally has no group
aliases. Rust and TypeScript source APIs keep deprecated `Group`, `groups`,
`group` and `generateGroup` aliases where possible; OpenAPI input keeps
`x-forge-group-name` and nested `groups` in `x-forge-commands` as deprecated
aliases. New code and documents should use `Namespace`, `namespaces`,
`namespace`, `generateNamespace`, `x-forge-namespace` and nested `namespaces`.

A plugin fails by throwing; forge stops and prints the message. Its files
go through the same checks as forge's own: a path must be relative, use `/`,
have no `.` or `..` parts, and no two files may share one.

Things to know:

- **Each group runs in an instance of its own.** `generateGroup` sees
nothing that `generate` or another group kept in module state.
- **Each namespace runs in an instance of its own.** `generateNamespace` sees
nothing that `generate` or another namespace kept in module state.
- **There is no Node.** Plugins have no `fs`, `process`, network or clock.
Libraries in plain JavaScript bundle in and work.
- **QuickJS runs the plugin.** `forge-plugin build` compiles it in, for a
Expand Down Expand Up @@ -248,8 +253,8 @@ to the file. `Client`, which the file declares, isn't imported, so the other
`Client` gets a `$`. `Pet` is used as both a type and a value, so it's
imported once, as a value; `Temporal.Instant` is imported as `Temporal`.

A plugin puts these together. This one writes a `models.ts` for each group,
importing the models it refers to from the groups they're in:
A plugin puts these together. This one writes a `models.ts` for each namespace,
importing the models it refers to from the namespaces they're in:

```ts
import { definePlugin, type Model, type Type } from '@cloudflare/forge/plugin';
Expand All @@ -262,7 +267,7 @@ function ts(type: Type): Part {
case 'union':
return join(' | ', type.members.map(ts));
case 'model':
// Imported from the models.ts of the model's group.
// Imported from the models.ts of the model's namespace.
return frag`${ident.model(type.model).ty()}`;
default:
return frag`unknown`;
Expand All @@ -279,10 +284,10 @@ function models(dir: Module, models: readonly Model[]): File {
return f;
}

export const { generate, generateGroup } = definePlugin({
// The models in no group, which more than one top-level group uses.
export const { generate, generateNamespace } = definePlugin({
// The models in no namespace, which more than one top-level namespace uses.
generate: (forge) => [models(module.ROOT, forge.models)],
generateGroup: (group) => [models(module.group(group), group.models)],
generateNamespace: (namespace) => [models(module.namespace(namespace), namespace.models)],
});
```

Expand All @@ -299,8 +304,8 @@ export const { generate, generateGroup } = definePlugin({
- `module("pets/models.ts")` is a generated file, which other files import
as `./models.js`, and `module("zod")` a package. `m.ident(name)`, or
`ident(name, m)`, is an identifier it declares; `.ty()` marks it as a
type, and `.as(alias)` imports it under another name. `module.group(group)`
is a group's directory, `module.ROOT` the output directory's, and
type, and `.as(alias)` imports it under another name. `module.namespace(namespace)`
is a namespace's directory, `module.ROOT` the output directory's, and
`ident.model(model)` a model's identifier.
- `file(path)` returns a `File`, which is a plugin's file as it is: one with
nothing printed isn't written, and its text starts with a preamble marking
Expand Down
2 changes: 1 addition & 1 deletion packages/forge/bin/forge-plugin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const usage = `usage: forge-plugin build <entry> [-o plugin.wasm]
forge-plugin wit

build compiles the plugin whose entry module is <entry>, a .ts or .js file
that exports definePlugin's generate and generateGroup, into a
that exports definePlugin's generate and generateNamespace, into a
WebAssembly component with QuickJS. Run it with:
forge openapi.json --target wasm://plugin.wasm --out sdk
-o, --out the component to write (default: plugin.wasm)
Expand Down
36 changes: 18 additions & 18 deletions packages/forge/plugin/forge.wit
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@

package forge:plugin@0.3.0;
package forge:plugin@0.4.0;

/// Read-only access to the forge a plugin runs on, which the host keeps.
/// Each id is an index the host gave out. An id the host never gave out
Expand All @@ -8,7 +8,7 @@ package forge:plugin@0.3.0;
/// Everything a part has is returned in one call, and orders and meanings
/// are as the Rust API documents them.
interface forge {
type group-id = u32;
type namespace-id = u32;
type method-id = u32;
type model-id = u32;
type type-id = u32;
Expand All @@ -30,19 +30,19 @@ interface forge {

/// How many of each part the forge has. Ids run from 0 to the count.
record forge-counts {
groups: u32,
namespaces: u32,
methods: u32,
models: u32,
types: u32,
}

record group-info {
record namespace-info {
name: name,
description: string,
/// The groups from the top level down to this one, ending with it.
path: list<group-id>,
/// The groups directly inside, sorted by name.
children: list<group-id>,
/// The namespaces from the top level down to this one, ending with it.
path: list<namespace-id>,
/// The namespaces directly inside, sorted by name.
children: list<namespace-id>,
/// The methods directly inside, sorted by name.
methods: list<method-id>,
models: list<model-id>,
Expand Down Expand Up @@ -89,7 +89,7 @@ interface forge {

record method-info {
name: name,
group: group-id,
namespace: namespace-id,
http-method: http-method,
path: string,
summary: string,
Expand Down Expand Up @@ -117,7 +117,7 @@ interface forge {
record model-info {
schema-name: string,
name: name,
group: option<group-id>,
namespace: option<namespace-id>,
description: string,
deprecated: bool,
ty: type-id,
Expand Down Expand Up @@ -191,11 +191,11 @@ interface forge {

info: func() -> forge-info;
counts: func() -> forge-counts;
/// Every group, depth first.
groups: func() -> list<group-id>;
/// The models at the top level, in no group.
/// Every namespace, depth first.
namespaces: func() -> list<namespace-id>;
/// The models at the top level, in no namespace.
models: func() -> list<model-id>;
get-group: func(id: group-id) -> group-info;
get-namespace: func(id: namespace-id) -> namespace-info;
get-method: func(id: method-id) -> method-info;
get-model: func(id: model-id) -> model-info;
get-type: func(id: type-id) -> type-info;
Expand All @@ -206,7 +206,7 @@ interface forge {

world plugin {
import forge;
use forge.{group-id};
use forge.{namespace-id};

/// A generated file: its path relative to the output directory, using
/// `/` as a separator, and its content. A file with no content is not
Expand All @@ -218,7 +218,7 @@ world plugin {

/// Generates files for the forge. Called exactly once.
export generate: func() -> result<list<file>, string>;
/// Generates files for a group. Called exactly once for each group, in
/// parallel with other groups, each in an instance of its own.
export generate-group: func(group: group-id) -> result<list<file>, string>;
/// Generates files for a namespace. Called exactly once for each namespace, in
/// parallel with other namespaces, each in an instance of its own.
export generate-namespace: func(namespace: namespace-id) -> result<list<file>, string>;
}
Loading
Loading