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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,12 @@ npm release are grouped under the in-development version that introduced them.

### Fixed

- **`@stitchapi/openapi` refuses to overwrite a file it did not generate.**
([#694](https://github.com/rejifald/StitchAPI/issues/694)) A re-run wrote every emitted file
unconditionally at exit `0`, so `--out <a directory you already own>` silently discarded
hand-written edits. It now reads back the previous run's `.stitch-gen.json` and refuses to touch
anything that manifest does not claim — naming the files, exiting non-zero — with `--force` to opt
in. That manifest also records the validator tier actually **emitted**, not the one requested.
- **`axiosAdapter(axios)` — the adapter's own documented snippet — now typechecks against real
axios.** ([#708](https://github.com/rejifald/StitchAPI/issues/708)) `AxiosLikeConfig.responseType`
was `string`, which axios types as its narrower `ResponseType` union, so passing `axios` (or
Expand Down
5 changes: 4 additions & 1 deletion packages/openapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ npx @stitchapi/openapi ./openapi.json --all --dry-run
| `--grep <substr>` | only operations whose path contains this substring |
| `--layout dir\|flat` | `dir` = one folder per operation (default); `flat` = one file |
| `--validator <t>` | `types-only` (default). valibot/zod tiers are not implemented yet |
| `--force` | overwrite files it did not generate (it refuses by default) |
| `--dry-run` | print the files to stdout instead of writing them |

## What it emits
Expand All @@ -42,7 +43,9 @@ one stitch per operation typed via `stitch<T>()`, and **atomic** component types
placed by fan-in — a schema used by ≥2 operations goes to `_shared/`, one used by
a single operation lives **inside that operation's directory** so deleting the
operation deletes its private types too. A `.stitch-gen.json` manifest records the
ownership graph.
ownership graph and every path the run wrote — so a re-run replaces what it
generated last time and **refuses to overwrite anything else**, naming the files
and exiting non-zero unless you pass `--force`.

```
src/pet-client/
Expand Down
107 changes: 100 additions & 7 deletions packages/openapi/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
// `stitch-openapi` — eject a SELECTED set of operations from an OpenAPI document into ready-to-own
// stitch source (ADR 0013). Thin wrapper over the pure `planGen`: parse argv, read the spec (JSON
// natively; YAML via the `yaml` dependency, imported lazily), then write the files (or print them
// on --dry-run).
import { type GenOptions, type OpenApiDoc, planGen } from './gen-openapi';

import { mkdir, readFile, writeFile } from 'node:fs/promises';
// on --dry-run). Writing is guarded: a file the previous run's manifest does not claim belongs to
// the author, and is never overwritten without --force.
import {
type GenOptions,
MANIFEST_FILE,
type OpenApiDoc,
planGen,
} from './gen-openapi';

import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';

const USAGE = `stitch-openapi — eject selected operations from an OpenAPI document into stitch source

usage:
stitch-openapi <spec> --out <dir> [--all | --tag <t> | --only <id> | --grep <s>]
[--layout dir|flat] [--validator types-only] [--dry-run]
[--layout dir|flat] [--validator types-only] [--force] [--dry-run]

<spec> an OpenAPI 3.x document (JSON or YAML)
--out, -o <dir> output directory (required unless --dry-run)
Expand All @@ -21,6 +27,7 @@ usage:
--grep <substr> only operations whose path contains this substring
--layout dir|flat dir = one folder per operation (default); flat = one file per operation
--validator <t> types-only (default; v1). valibot/zod tiers are not implemented yet
--force overwrite files this ejector did not generate (it refuses by default)
--dry-run print the files to stdout instead of writing them

Ejects ready-to-own source you edit afterward: a client.ts seam, one stitch per operation typed
Expand All @@ -36,6 +43,7 @@ interface Io {
writeErr: (s: string) => void;
readFileText: (path: string) => Promise<string>;
writeFile: (path: string, contents: string) => Promise<void>;
exists: (path: string) => Promise<boolean>;
}

function defaultIo(): Io {
Expand All @@ -48,9 +56,68 @@ function defaultIo(): Io {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, contents, 'utf8');
},
exists: async (path) => {
try {
await access(path);
return true;
} catch {
return false;
}
},
};
}

function asArray(v: unknown): unknown[] {
return Array.isArray(v) ? (v as unknown[]) : [];
}

/**
* Paths (relative to --out) that a PREVIOUS run of this ejector wrote there, per the manifest it
* left behind. Everything else on disk belongs to the author — an edited `client.ts`, a hand-written
* helper — and is not ours to replace (#694 §1). No manifest means no previous run: own nothing.
*/
async function ownedPaths(io: Io, outDir: string): Promise<Set<string>> {
const owned = new Set<string>();
let raw: string;
try {
raw = await io.readFileText(resolve(outDir, MANIFEST_FILE));
} catch {
return owned;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
io.writeErr(
`warning: ${MANIFEST_FILE} is not valid JSON; treating every existing file as yours\n`,
);
return owned;
}
const m = (
typeof parsed === 'object' && parsed !== null ? parsed : {}
) as Record<string, unknown>;

// The exact list, written by every run since the guard shipped.
if (Array.isArray(m['files'])) {
for (const p of asArray(m['files']))
if (typeof p === 'string') owned.add(p);
return owned;
}
// Older manifests predate that list but still record the ownership graph, so derive from it —
// adopting a tree this ejector really did generate should not cost a blanket --force. The three
// constants are emitted unconditionally by every run (client.ts, index.ts, the manifest itself).
owned.add(MANIFEST_FILE);
owned.add('client.ts');
owned.add('index.ts');
for (const e of [...asArray(m['operations']), ...asArray(m['schemas'])]) {
const f = (e as { file?: unknown }).file;
// A flat-layout private schema records `<op>.ts (inlined)` — a marker, not a path.
if (typeof f === 'string' && f !== '' && !f.endsWith('(inlined)'))
owned.add(f);
}
return owned;
}

export async function main(
argv: string[],
overrides: Partial<Io> = {},
Expand All @@ -64,6 +131,7 @@ export async function main(
let spec: string | undefined;
let out: string | undefined;
let dryRun = false;
let force = false;
const opts: GenOptions = {};
const tags: string[] = [];
const only: string[] = [];
Expand All @@ -87,7 +155,8 @@ export async function main(
} else if (a === '--layout') {
const v = argv[++i];
if (v) opts.layout = v as NonNullable<GenOptions['layout']>;
} else if (a === '--dry-run') dryRun = true;
} else if (a === '--force') force = true;
else if (a === '--dry-run') dryRun = true;
else if (!a.startsWith('-') && spec === undefined) spec = a;
else io.writeErr(`warning: ignored unknown arg: ${a}\n`);
}
Expand Down Expand Up @@ -143,8 +212,32 @@ export async function main(
}

const base = out as string;
const outDir = resolve(io.cwd, base);

// Eject, not managed regeneration (ADR 0013): the tree is the author's once written, so a re-run
// may replace only what a previous run put there. Anything else that already exists is theirs.
if (!force) {
const owned = await ownedPaths(io, outDir);
const clobbered: string[] = [];
for (const f of result.files) {
if (owned.has(f.path)) continue; // ours from last time — the normal regen path
if (await io.exists(resolve(outDir, f.path)))
clobbered.push(f.path);
}
if (clobbered.length > 0) {
io.writeErr(
`refusing to overwrite ${clobbered.length} file(s) in ${base} that this generator did not write (no record of them in ${MANIFEST_FILE}):\n`,
);
for (const p of clobbered) io.writeErr(` ${p}\n`);
io.writeErr(
'Nothing was written. Eject into a scratch directory and diff (ADR 0013 Decision 1), or pass --force to overwrite them.\n',
);
return 1;
}
}

for (const f of result.files)
await io.writeFile(resolve(io.cwd, base, f.path), f.contents);
await io.writeFile(resolve(outDir, f.path), f.contents);
io.writeErr(`wrote ${result.files.length} file(s) to ${base}\n`);
return 0;
}
22 changes: 18 additions & 4 deletions packages/openapi/src/gen-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ const HTTP_METHODS = [
'trace',
] as const;

/** The ownership manifest, written into --out (ADR 0013 Decision 9). Read back by the CLI. */
export const MANIFEST_FILE = '.stitch-gen.json';

// ---- options & result -----------------------------------------------------

export interface GenOptions {
Expand Down Expand Up @@ -408,12 +411,17 @@ function directRefs(schema: SchemaNode | undefined, acc: Set<string>): void {
export function planGen(doc: OpenApiDoc, opts: GenOptions = {}): GenResult {
const warnings: string[] = [];
const notices: string[] = [];
const validator = opts.validator ?? 'types-only';
const requested = opts.validator ?? 'types-only';
// The tier actually EMITTED. v1 has no valibot/zod emitter, so it is always types-only — and
// the manifest, which is the durable artefact, records THIS rather than what was asked for.
// Recording the request left `"validator": "zod"` sitting over a tree with no validators in it,
// with only a build-time stderr line to contradict it (#694 §3).
const validator = 'types-only';
const layout = opts.layout ?? 'dir';

if (validator !== 'types-only') {
if (requested !== validator) {
warnings.push(
`validator "${validator}" is not implemented in v1; emitting types-only`,
`validator "${requested}" is not implemented in v1; emitting types-only`,
);
}
notices.push(
Expand Down Expand Up @@ -629,6 +637,11 @@ export function planGen(doc: OpenApiDoc, opts: GenOptions = {}): GenResult {
generator: 'stitch gen openapi',
validator,
layout,
// Every path this run writes, relative to --out, INCLUDING this manifest. It is the durable
// record of what the ejector OWNS: the CLI reads it back on the next run and refuses to
// overwrite anything absent from it (#694 §1). The ownership graph below records file names
// too, but only for operations and non-inlined schemas — this list is exact by construction.
files: [...files.map((f) => f.path), MANIFEST_FILE].sort(),
operations: selected.map((o) => ({
name: o.name,
method: o.method,
Expand All @@ -643,7 +656,7 @@ export function planGen(doc: OpenApiDoc, opts: GenOptions = {}): GenResult {
schemas: schemaManifest,
};
files.push({
path: '.stitch-gen.json',
path: MANIFEST_FILE,
contents: `${JSON.stringify(manifest, null, 2)}\n`,
});

Expand Down Expand Up @@ -966,6 +979,7 @@ function emptyManifest(validator: string, layout: string): unknown {
generator: 'stitch gen openapi',
validator,
layout,
files: [],
operations: [],
schemas: [],
};
Expand Down
Loading
Loading