Repository navigation
RFC: conformance suite for forge generators #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
teamchong
wants to merge
4
commits into
cloudflare:main
Choose a base branch
from
teamchong:feat/conformance-spec
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,266 @@ | ||
| # RFC: A conformance suite for forge targets | ||
|
|
||
| ## Summary | ||
|
|
||
| A conformance suite is an API description and a set of cases for it, | ||
| written as data, that any forge target can run to show its generated client | ||
| handles every pattern an API description can contain. A standalone runner | ||
| executes the cases against a small client the target provides. | ||
|
|
||
| ## Motivation | ||
|
|
||
| If I write a new target plugin, how do I know it covers all the cases? | ||
|
|
||
| Today there is no answer short of reading other targets. The suite answers | ||
| it: generate a client for the suite API, wrap it in a small program that | ||
| speaks the protocol below, and run the runner. Every case that passes is a | ||
| pattern the target handles; every case that fails names the one that broke. | ||
|
|
||
| The runner is a tool for any target, in this repo or not. A target maintained | ||
| elsewhere uses it the same way. | ||
|
|
||
| ## Overview | ||
|
|
||
| ``` | ||
| cases (YAML) | ||
| | | ||
| runner <-- HTTP --> generated client | ||
| | ^ | ||
| +---- JSON lines ---> target's client program | ||
| ``` | ||
|
|
||
| 1. The **suite API** is an OpenAPI document that contains every pattern a | ||
| target must handle. | ||
| 2. The target generates a client from it, and wraps it in a **client | ||
| program**. | ||
| 3. The **runner** reads the cases, starts the client program, sends it each | ||
| case, serves the HTTP exchange the case expects, and compares the result | ||
| the client program reports. | ||
|
|
||
| The runner does not generate or build anything. It is given a command to | ||
| start, and knows nothing about the target's language or toolchain. | ||
|
|
||
| ## The suite API | ||
|
|
||
| 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 | | ||
|
|
||
| Every method in the suite API has at least one case. | ||
|
|
||
| ## The shape of a case | ||
|
|
||
| A case is one call, the HTTP exchange the server must see, and the result | ||
| the caller must get. Cases are YAML files, each a list of cases. | ||
|
|
||
| ```yaml | ||
| - name: an object body is sent in wire names | ||
| variables: { orgId: o1 } | ||
| call: | ||
| group: projects | ||
| method: create | ||
| body: { displayName: Demo } | ||
| exchange: | ||
| expect: | ||
| method: POST | ||
| path: /orgs/o1/projects | ||
| headers: { content-type: application/json } | ||
| body: { display_name: Demo } | ||
| respond: | ||
| status: 201 | ||
| body: { id: p1, display_name: Demo } | ||
| result: | ||
| kind: response | ||
| value: { id: p1, displayName: Demo } | ||
| ``` | ||
|
|
||
| ### Schema | ||
|
|
||
| ```ts | ||
| 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 | ||
| 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 | ||
| body?: Value; // request body, with object keys in field names | ||
| }; | ||
| exchange: Exchange; | ||
| result: Result; | ||
| }; | ||
|
|
||
| type Exchange = { | ||
| expect: { | ||
| method: string; | ||
| path: string; // escaped, without the base URL | ||
| query?: Record<string, string | string[]>; // one key per name; repeated name: array | ||
| headers?: Record<string, string | string[]>; // only listed headers are checked | ||
| body?: Json; | ||
| }; | ||
| respond: | ||
| | { status: number; headers?: Record<string, string | string[]>; body?: Json } | ||
| | { status: number; headers?: Record<string, string | string[]>; text: string }; | ||
| }; | ||
|
|
||
| type Result = | ||
| | { kind: 'response'; value?: Value } // the method returned; no value when the response has no body | ||
| | { kind: 'api'; status: number; message?: string; details?: Value }; // an error response | ||
| ``` | ||
|
|
||
| `exchange` is explicit because the runner does not read the OpenAPI document | ||
| or know how a target maps `call` to HTTP: the case pins the exact wire path, | ||
| query, headers and body the SDK must send, and the exact status and wire body | ||
| the server returns (including error payloads and non-JSON text that cannot be | ||
| derived from `call`). | ||
|
|
||
| Of an `api` result, only the members the case lists are compared. | ||
|
|
||
| ### Call inputs | ||
|
|
||
| `call` splits inputs into `path`, `query`, `headers` and `body` so a target | ||
| can pass each where its generated signature expects: one combined request | ||
| object, positional path arguments plus a body, or separate parameter and body | ||
| structs. An SDK variable in `variables` is set on the client; if the same | ||
| variable also appears in `call.path`, the call's value wins. | ||
|
|
||
| ### Query and headers | ||
|
|
||
| A query parameter or header sent once is a string. One sent several times is | ||
| an array, its values in the order sent. Different keys may come in any order. | ||
| For example, `?tag=a&limit=10&tag=b` is: | ||
|
|
||
| ```yaml | ||
| query: { tag: [a, b], limit: '10' } | ||
| ``` | ||
|
|
||
| Header names are compared without case. A header sent as several lines | ||
| matches the same values joined with `, ` on one line, as HTTP treats them | ||
| the same. | ||
|
|
||
| ### Names | ||
|
|
||
| The two sides of a case use different names: | ||
|
|
||
| | Part | Names | | ||
| | ---------------------------- | --------------------------------------------- | | ||
| | `exchange` (server side) | wire names, exactly as in the API description | | ||
| | `variables`, `call`, `value` | forge field names, in camelCase | | ||
|
|
||
| camelCase is the canonical form, not a requirement on targets. A target whose | ||
| language uses another convention converts case names the same way its | ||
| generator converts field names, e.g. `displayName` to `display_name`. | ||
|
|
||
| ### Values | ||
|
|
||
| `Value` is JSON plus tagged values for types JSON cannot hold: | ||
|
|
||
| | Type | Written as | | ||
| | ----------- | ----------------------------------------- | | ||
| | `date-time` | `{ "$datetime": "2024-03-01T10:00:00Z" }` | | ||
| | `date` | `{ "$date": "2024-03-01" }` | | ||
| | `int64` | `{ "$int64": "9007199254740993" }` | | ||
| | `byte` | `{ "$bytes": "aGVsbG8=" }` | | ||
|
|
||
| Tagged values compare by what they hold, so `2024-03-01T12:00:00+02:00` and | ||
| `2024-03-01T10:00:00Z` are the same `$datetime`. | ||
|
|
||
| ## Implementing a target | ||
|
|
||
| A target provides one client program. It reads one case per line on standard | ||
| input and writes one result per line on standard output. | ||
|
|
||
| Input, the case's client side plus where to send requests: | ||
|
|
||
| ```json | ||
| { | ||
| "id": "an-object-body-is-sent-in-wire-names", | ||
| "baseUrl": "http://127.0.0.1:52100/", | ||
| "token": "conformance-token", | ||
| "variables": { "orgId": "o1" }, | ||
| "call": { "group": "projects", "method": "create", "body": { "displayName": "Demo" } } | ||
| } | ||
| ``` | ||
|
|
||
| Output, with the same id and a `Result`, or `unexpected`: | ||
|
|
||
| ```json | ||
| { "id": "…", "result": { "kind": "response", "value": { "id": "p1", "displayName": "Demo" } } } | ||
| { "id": "…", "result": { "kind": "api", "status": 404, "message": "…", "details": {} } } | ||
| { "id": "…", "result": { "kind": "unexpected", "message": "…" } } | ||
| ``` | ||
|
|
||
| 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`; | ||
| 3. maps the return value or error to a `Result`, converting tagged values to | ||
| and from the language's types. | ||
|
|
||
| The program has one handler per method of the suite API, written as a user | ||
| of the SDK would call it. | ||
|
|
||
| `unexpected` is an error other than an `api` error, a bug in the SDK or the | ||
| program, and always fails the case. Results may come in any order. The program | ||
| exits when standard input closes. | ||
|
|
||
| ## The runner | ||
|
|
||
| ```sh | ||
| forge-conformance --cases ./cases --client "node ./client.js" | ||
| ``` | ||
|
|
||
| It plays both ends of every case: | ||
|
|
||
| - **Server.** A request matches the exchange when the method and escaped path | ||
| are equal, the query and the listed headers have the same values, and the | ||
| body is the same JSON value. The server responds with the exchange's | ||
| `respond` even on a mismatch, so the failure reports what went wrong on the | ||
| wire, not what followed from it. | ||
| - **Caller.** It sends each case to the client program and compares the | ||
| result. | ||
|
|
||
| A case fails when the request does not match the exchange, when an extra | ||
| request arrives, or when none arrives; when the result differs; or when no | ||
| result comes within a timeout. A failure shows the case's file, line and | ||
| name, and a diff. | ||
|
|
||
| ### Skipping and expecting failures | ||
|
|
||
| ```sh | ||
| forge-conformance --cases ./cases --client "./client" \ | ||
| --skip "an undeclared status keeps its body as text" \ | ||
| --skip @./skip.txt \ | ||
| --expect-fail ./expected-failures.txt \ | ||
| --write-failures ./failures.txt | ||
| ``` | ||
|
|
||
| - `--skip <name>` does not run the case. Repeatable. `--skip @<file>` does | ||
| not run the cases in a case list. | ||
| - `--expect-fail <file>` is a case list of cases expected to fail. A listed | ||
| case that fails is reported and does not fail the run. A listed case that | ||
| passes, or a name that is no case, fails the run, so the list only shrinks. | ||
| - `--write-failures <file>` writes the cases that failed as a case list, so | ||
| it can become the `--expect-fail` file. | ||
|
|
||
| A case list is plain text, one case name per line. Blank lines and lines | ||
| starting with `#` are ignored; a comment above a name gives the reason: | ||
|
|
||
| ``` | ||
| # status ranges are not generated yet | ||
| an error declared for a range is decoded | ||
| ``` | ||
|
|
||
| `--write-failures` puts each case's first failure in that comment. | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.