Skip to content
Open
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
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ API documentation site for Forge OpenAPI specs, built on Astro.
- **`packages/fern-forge`**: `astro-fern` extension that validates and adapts Forge OpenAPI metadata.
- **`packages/docs-site`**: API documentation website consuming `astro-fern` and `fern-forge`. The OpenAPI spec is fetched from the `openapi.forge.json` GitHub release asset (or a local file via `FORGE_OPENAPI_SPEC`).
- **`packages/cloudflare-terraform-docs`**: Generator for the site's Terraform documentation data, derived from the released Cloudflare Terraform provider. Temporary: removed once Forge supports Terraform natively (see the site's README).
- **`src/`**: The Rust engine: OpenAPI to linked model to plugin host. It reads only native `x-forge-*` extensions (`src/metadata/`); other tools' extensions are rewritten into them by `forge normalize` (`src/normalize/`).

## Vendor extensions

Compatibility with other tools' extensions (`x-fern-*`, `x-stainless-*`, and `x-sensitive`) is sectioned off into its own area, `src/normalize/vendor/`, and nowhere else in the codebase knows them. `forge` does not read them: they are ordinary extensions to it, and `forge normalize` is what rewrites them into native ones. Any new compatibility key belongs in that area. See [`src/normalize/vendor/README.md`](src/normalize/vendor/README.md).

- Never name a vendor extension outside `src/normalize/vendor/` and `tests/normalize/`, comments and docs included. `tests/boundary.rs` enforces this (`just boundary`).
- Run `forge normalize --write --leave-old-extensions` on a vendor-keyed document before `forge` reads it. The old keys stay for tools that still read them. Until the release that makes this step required, the `forge` command also does it in memory when it reads a document (`before_reading` in `src/bin/forge/normalize.rs`); that call is temporary, and the engine in `src/` never does it.
- Support for a new vendor key means mapping it onto an existing native primitive in an adapter. Add the native key first if there is none.
- The docs packages (`astro-fern`, `fern-forge`, `cloudflare-api-site`) read vendor keys themselves and are not covered by the boundary.

## Conventions

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ edition = "2024"

[dependencies]
clap = { version = "4.6.7", features = ["derive"], optional = true }
memchr = "2.8.3"
rayon = "1.12.0"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = { version = "1.0.151", optional = true }
serde_json = "1.0.151"
wasmtime = { version = "49.0.2", default-features = false, features = ["runtime", "cranelift", "component-model", "parallel-compilation", "std"], optional = true }
wasmtime-wasi = { version = "49.0.2", default-features = false, features = ["p2"], optional = true }

[dev-dependencies]
divan = "0.1.21"
serde_json = "1.0.151"

[[bench]]
name = "parse"
Expand All @@ -26,7 +26,7 @@ harness = false
default = ["cli"]
# The `forge` command. Library users can turn it off with
# `default-features = false`.
cli = ["dep:clap", "dep:serde_json"]
cli = ["dep:clap"]
# `forge::wasm`, which runs plugins compiled to WebAssembly components.
wasm = ["dep:wasmtime", "dep:wasmtime-wasi"]

Expand Down
4 changes: 4 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ lint:
cargo clippy --locked --all-targets --all-features -- -D warnings
cargo clippy --locked --all-targets --no-default-features -- -D warnings

# Check that no other tool's extension is named outside the vendor tables of `forge normalize`
boundary:
cargo test --locked --test boundary

# Release builds
#
# Every way of getting release binaries puts them in target/binaries/<target>/
Expand Down
56 changes: 56 additions & 0 deletions packages/forge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,62 @@ npx @cloudflare/forge openapi.json --target ts --out sdk
Pass `-` instead of a path to read the document from standard input. Run
`forge --help` for every option.

## Normalizing a document

Forge's own metadata is the `x-forge-*` extensions, and they are the only
ones it reads. Documents that were written for other tools carry those tools'
extensions instead, and `forge normalize` rewrites them into the native ones.
Run it before forge reads the document, with `--leave-old-extensions` if
something else still reads the old keys:

```sh
# Print a document with its keys rewritten. Nothing is changed.
npx forge normalize openapi.json

# Rewrite the files in place.
npx forge normalize --write specs/*.json
```

For now, `forge` also does this in memory when it reads a document, so one that
has not been through `forge normalize` builds as it always did. That is
temporary: it stops in the release that makes `forge normalize` a step to run
first, and the extensions of other tools are then passed on to plugins like any
other that forge does not read.

It takes paths, which the shell can expand (`specs/*.json`). Without `--write` it
changes nothing: it prints one document, rewritten, to standard output. With
`--write` it rewrites each file in place, and only if there is something to
rewrite in it, and it prints a line for each file to standard error, and `-v`
adds how many times each key was rewritten. Several files need `--write`, or
`--check`. A link is followed, and stays a link, and
so does a hard link; a file that is given twice is rewritten once; and a file
that is read-only is left as it is, with an error. Documents have to be JSON:
YAML is not supported yet, and neither is a byte order mark. A file that is
not valid JSON is an error, and is left as it is.

Only the keys change: the rest of the file, including its whitespace, the
order of its keys and the text of every number, stays byte for byte as it was,
so a diff of the file is the keys. The one value that is written again is a
group path, which becomes the names joined by dots (`["a", "b"]` becomes
`"a.b"`); a path that a dotted string cannot say, such as a name with a dot in
it, is kept as it was. A skip for only some targets, which another tool writes
as a list of them, has no native form, since `x-forge-skip` is for every target:
it is left exactly as it is written, whichever mode is used, while a skip that
is plainly true or false is rewritten. Native keys always win: when a document
says the same thing twice, the native key is kept, and the other is reported
as a warning if it says something else.

```sh
# Keep the old keys beside the native ones, for what still reads them.
npx forge normalize --write --leave-old-extensions openapi.json

# Write nothing, and fail if anything would be rewritten: for a check in CI.
npx forge normalize --check specs/*.json

# Read from standard input, write to standard output, and generate from that.
cat openapi.json | npx forge normalize - | npx forge - --target ts --out sdk
```

## Plugins

A plugin generates an SDK, or anything else, from the document: forge parses
Expand Down
2 changes: 1 addition & 1 deletion packages/forge/plugin/forge.wit
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ interface forge {
json: option<u32>,
}

/// The stage of the API a method is in, from `x-fern-availability`.
/// The stage of the API a method is in, from `x-forge-availability`.
enum availability {
alpha, beta, preview, generally-available, deprecated,
}
Expand Down
6 changes: 3 additions & 3 deletions packages/forge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export interface Method {
* way around.
*/
readonly deprecated: boolean;
/** The stage of the API the method is in, from `x-fern-availability`, or undefined if it doesn't say. */
/** The stage of the API the method is in, from `x-forge-availability`, or undefined if it doesn't say. */
readonly availability: Availability | undefined;
/**
* Whether to leave the method out of listings of methods, such as a CLI's
Expand Down Expand Up @@ -197,7 +197,7 @@ export interface Method {
*/
export type Location = { readonly in: 'path' } | { readonly in: 'query'; readonly exploded: boolean };

/** The stage of the API a method is in, as `x-fern-availability` writes it. */
/** The stage of the API a method is in, as `x-forge-availability` writes it. */
export type Availability = 'alpha' | 'beta' | 'preview' | 'generally-available' | 'deprecated';

/**
Expand Down Expand Up @@ -267,7 +267,7 @@ export interface Field {
* though the same model's field appears in responses: `readOnly`.
*/
readonly readOnly: boolean;
/** Whether the value is a secret, such as a password or a token, to keep out of logs: `x-sensitive`. */
/** Whether the value is a secret, such as a password or a token, to keep out of logs: `x-forge-sensitive`. */
readonly sensitive: boolean;
/** The `default` of the property's schema, or undefined if it has none. */
readonly default: Json | undefined;
Expand Down
76 changes: 76 additions & 0 deletions rfcs/0001-metadata-resolution-layer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# RFC 0001: Native metadata, and `forge normalize`

## Summary

`forge` reads a native `x-forge-*` vocabulary and nothing else. The extensions other tools write (`x-fern-*`, `x-stainless-skip`, `x-sensitive`) are ordinary extensions to it. A separate command, `forge normalize`, rewrites them into the native ones, so the spec that is produced is itself what gets transformed, and the compatibility knowledge is a layer of its own.

## Motivation

Vendor names were hardcoded in the readers, in value formats, in error messages, in the keys kept for plugins and in the public docs, so everything downstream inherited a vendor's vocabulary, and adding a vendor meant touching all of it. Reading them at runtime, even as a fallback, also means a ranking between keys and a vendor key that silently diverges from the native key beside it. Rewriting them in an explicit step makes the change visible as a diff, and reports a conflict.

## Scope

- `forge` reads native keys only: no tiers, no fallback, no feature to turn on.
- Nothing outside `src/normalize/vendor/` and `tests/normalize/` names a vendor extension, enforced by a test.
- A document, after `forge normalize`, builds what it built before.

Not in scope: Fern's structural behavior (allOf/anyOf, inline request types), operation ID prefixing, and the docs packages, which read vendor keys themselves. Vendor keys with no Forge primitive are neither read nor rewritten.

## The native vocabulary

| Native key | Value | Meaning | Rewritten from |
| ------------------------ | --------------------------------------------------------------- | ------------------------------------------------- | ---------------------------------------------------------- |
| `x-forge-method-name` | string | the method's name | `x-fern-sdk-method-name` |
| `x-forge-group-name` | dotted string, or array of strings | the group path; an empty path makes no method | `x-fern-sdk-group-name` |
| `x-forge-skip` | bool | operation or alias: no method; property: no field | `x-fern-ignore` (operation), `x-stainless-skip` (property) |
| `x-forge-availability` | `alpha`, `beta`, `preview`, `generally-available`, `deprecated` | lifecycle stage | `x-fern-availability` |
| `x-forge-model-name` | string | the model's name | `x-fern-type-name` |
| `x-forge-property-name` | string | the property's field name | `x-fern-property-name` |
| `x-forge-parameter-name` | string | the parameter's field name | `x-fern-parameter-name` |
| `x-forge-sensitive` | bool | the value is a secret, to mask | `x-sensitive`, which other systems write too |

`x-forge-hidden`, `x-forge-require-confirmation`, `x-forge-aliases` and `x-forge-commands` are unchanged.

- Explicit keys, not one positional name key: tools that inline `$ref` targets would make a positional key silently rename fields.
- `skip`, not `ignore`: it says what happens, and stands apart from `x-forge-hidden`. One word covers operations and properties.
- Groups are a dotted string or an array, so a move from Cloudflare's dotted strings is a rename. A string is names separated by dots; an array is the names, which are not split. FORGE-3 expects to rename the key to `x-forge-namespace`.
- A native key at the wrong level is ignored. Validating later only tightens.

## What `forge normalize` does

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does forge normalize replace the input file?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It did when you made this comment, but I think it should not, so I made it require an explicit flag to do in place writes.

Without --write it prints the rewritten document to stdout and changes nothing; --write rewrites each file in place, and only if something in it changes. --check writes nothing and fails if anything would change, and - reads stdin. I've added this to the RFC.


- It changes nothing unless it is told to: one document is printed, rewritten, to standard output, and `--write` rewrites each file in place, only if something in it changes. `-` reads standard input and prints. `--check` writes nothing and fails if anything would change.
- A key is rewritten only at the kind of object `forge` reads its native key at: an operation or an alias, a parameter, or a schema.
- The document is edited as bytes, not written again: whitespace, key order and the text of every number stay as they were, so a diff is only the keys.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we even retain whitespace?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, deliberately. This is for teams migrating their own specs, in whatever layout and with whatever comments they have, and they'll commit the result. Editing in place keeps their diff to just the renamed keys. Rewriting from a tree reformats any file that isn't already in serde's layout. Key order is kept either way.

YAML input isn't supported yet, but can be added as a fast follow.

- A native key is never replaced. When a document says the same thing twice, the native key is kept, and the other is dropped, or reported if it says something else.
- A group path that is an array becomes the names joined by dots, unless a name has a dot in it.
- A skip on a property is rewritten to `x-forge-skip` with the same value: `x-stainless-skip: true` becomes `x-forge-skip: true`. `x-forge-skip` is for every target, so a skip for only some targets, written as a list (`x-stainless-skip: ["terraform"]`), has no native form and is left as it is written, in both modes.
- `--leave-old-extensions` keeps each key that is rewritten, beside the native one, for what still reads it.

## Architecture

```
src/
├─ metadata/ the native table, per kind of object: definition,
│ parameter, schema, document. Pure: `&str` in, small
│ values out.
├─ link/ reads native keys through `metadata`.
└─ normalize/
├─ vendor/ the tables of other tools' keys. The only code holding
│ them, sectioned off, and per level.
├─ walk.rs where a key is to be rewritten, and to what.
└─ scan.rs reading JSON as bytes, and editing it where it is read.
```

## Migration

- Run `forge normalize --write` before `forge` reads a document written with vendor keys, with `--leave-old-extensions` where something else still reads them. A pipeline is `cat spec.json | forge normalize - | forge - --target ts --out sdk`.
- Until the release that makes `forge normalize` a step to run first, the `forge` command does the same rewrite in memory when it reads a document, so one that has not been through it builds as before. It costs a pass over the document, and stops in that release; the core never reads anything but native keys.
- After that release, a document that is not normalized is not an error: `forge` finds no method names in it, and so no methods.
- Keys that are left are ordinary extensions, so plugins see them.

## Alternatives considered

- **A runtime fallback behind a feature**, native keys first and vendor keys for what has none: it costs tiers in the reader and a vendor key that diverges from the native one beside it. Rejected for an explicit rewrite.
- **Resolving after parsing, in the engine**: it must retain every extension of a large document.
- **Normalizing in memory before every `generate`, for good**: a pass over the document on every run, to save one in CI. Kept for now as a bridge, and not as the design.
- **A `Vendor` trait** per tool: no primitive overlaps across vendors, so the table only grows rows.
Loading