Skip to content

feat!: standalone operations layout, drop the .generated suffix - #8

Merged
pkurcx merged 4 commits into
mainfrom
feat/standalone-operations
Sep 8, 2026
Merged

feat!: standalone operations layout, drop the .generated suffix#8
pkurcx merged 4 commits into
mainfrom
feat/standalone-operations

Conversation

@pkurcx

@pkurcx pkurcx commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Standalone operations layout. layout: ['operations'] emits one defineOperation(...) constant per operation under rest/<group>/<method>.ts with a rest/<group>/index.ts barrel, so an app imports and bundles only the operations it uses. layout: ['services', 'operations'] adds the per-tag class bound from the barrel. ['services'] (the default) is byte-identical to today's output.
  • Runtime. One OperationImpl backs both forms: .observable() / .resource() resolve DI per call from options.injector or the current injection context (dev-only message with NG0203 as cause), .request() is pure, .withInjector() and the free withInjector(record) return the bound RequestFn. Every defineOperation call is /* @__PURE__ */ so barrel namespace imports tree-shake under esbuild and Rollup.
  • Drop the .generated suffix from emitted files: model.ts, rest/<group>.rest.ts, rest/<group>/<method>.ts.
  • Playground. Nested, collapsible file tree; layout accepted in the config editor.
  • Hardening. The planner rejects default and index method names under operations, two method names or two groups sharing a kebab file stem, and method names with no letters or digits.

Breaking changes

  • Consumer import paths lose the .generated segment: ./rest/pet.rest.generated./rest/pet.rest, ./model.generated./model.
  • Two operations resolving to one method name in a group, or two groups resolving to one file name, are now rejected (E_POLICY_VIOLATION / naming-resolution) instead of silently overwriting output.
  • New option layout: Array<'services' | 'operations'> (default ['services']); --layout on the CLI.

Verification

  • cargo test --all-targets, cargo clippy, bun run test (385), bun run lint
  • website: astro check, vitest, Playwright e2e against the locally built engine

Note on the Docs check

The Docs workflow builds the playground against the published @avsystem/openapi-ng@0.5.1, whose wrapper rejects the new layout option, so its Playwright run fails on this PR by construction. It goes green with the version bump that follows the release.

🤖 Generated with Claude Code

pkurcx and others added 4 commits September 5, 2026 16:31
Add a second Angular output layout in which every operation is a
top-level `defineOperation(...)` constant in its own file under
`rest/<group>/`, re-exported by a `rest/<group>.operations.generated.ts`
barrel. `both` also emits the per-tag class, built from
`ops.<method>.withInjector()`, with the `Params`/`Error` interfaces
re-exported so type imports keep resolving across layouts. The default
`services` output is byte-identical.

Runtime: one `OperationImpl` class backs both forms. Standalone
`.observable()`/`.resource()` resolve DI per call from `options.injector`
or the current injection context and throw a dev-only message with
NG0203 as `cause` otherwise; `.request()` is pure unless given an
injector. `.withInjector()` and the free `withInjector(record)` return
today's `RequestFn`; `requestFactory` is sugar over them. `validateRest`
accepts either form via `Resourceful`.

Generator: `Layout` option across config, napi, JS wrapper, CLI and
config file; per-operation and barrel paths in the plan; reserved words
such as `delete` are declared under an alias and exported by name;
`default` is rejected with `E_POLICY_VIOLATION`/`reserved-identifier`;
two operations resolving to one method name in a group are rejected.

Tests: Rust unit tests, labelled layout snapshots, consumer type proof,
CLI tests, and a runtime spec that loads the template under Node
(`@angular/compiler` dev dependency, `templates/package.json` for ESM).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WJqAgGwh52GqqMx1rfPzGn
Emitted files are now `model.ts`, `rest/<group>.rest.ts` and, for the
standalone layouts, `rest/<group>/<method>.ts` with the barrel at
`rest/<group>/index.ts` so a group is imported by its directory. The
runtime templates were already unsuffixed, every file carries the
do-not-edit banner, and the writer never used the suffix to find its
own output, so the marker only lengthened import paths.

Docs state the contract instead: the output directory belongs to the
generator, is committed or ignored as one unit, and is deleted before
regenerating after removals since stale files are never cleaned up.

BREAKING CHANGE: consumer import paths lose the `.generated` segment,
e.g. `./rest/pet.rest.generated` becomes `./rest/pet.rest` and
`./rest/pet.operations.generated` becomes `./rest/pet`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WJqAgGwh52GqqMx1rfPzGn
The operations layout emits `rest/<group>/<method>.ts`, two levels deep,
which the flat two-row tree could not show. Build the tree recursively,
render directories as toggle buttons with `aria-expanded`, and keep the
folded set across regenerations, unfolding only when the selected file
falls back into a folded directory. Add `layout` to the accepted config
options and the default JSONC template.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`layout` now mirrors `emit`: a list of `services` and/or `operations`
(`--layout services,operations` on the CLI, comma-separated and
repeatable). Listing both replaces the old `layout: 'both'`. The core
rejects an empty list and `operations` without the `angular` target; the
CLI and the wrapper share one allow-list and one list validator, and the
browser entry exports `Layout`.

Runtime: every `defineOperation` call carries `/* @__PURE__ */`, so
esbuild and Rollup drop operations a barrel namespace import never
touches. `.request()`, `.observable()` and `.resource()` agree that an
explicit `options.injector` wins over a `withInjector()` binding, and are
arrow properties so detached methods keep working. A bound `.resource()`
hands its options to `httpResource` untouched, so the resource lives in
the caller's injection context, as it did under `requestFactory`.

Planner: reject an operation named `index` (it would overwrite the
barrel, `reserved-identifier`), two method names sharing a kebab file
stem, a method name with no letters or digits, and two groups sharing a
file stem (`naming-resolution`). The barrel and the bound class derive
their import specifiers from the naming helpers instead of re-parsing
planned paths; the `default` rejection names the real reason
(`ops.default`).

Tests: unit tests for `emit_bound_service` and the new rejections, an
`index-method-name` fixture with success and failure snapshots, runtime
spec cases for detached methods, injector precedence and the resource
lifetime.

Docs: standalone-operation example and layouts bullet in the README, a
layouts card on the landing page, the new rejections on the limitations
and diagnostics pages, `Config.layout` as a list in the node-api
reference.

BREAKING CHANGE: `layout` is an array; `layout: 'both'` becomes
`layout: ['services', 'operations']`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@pkurcx
pkurcx merged commit eadcf2f into main Sep 8, 2026
37 of 38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant