Sitelet https://github.com/constantant/angular-openapi-gen/blob/master/CLAUDE.md
Skip to content

Latest commit

 

History

History
598 lines (476 loc) · 67.2 KB

File metadata and controls

598 lines (476 loc) · 67.2 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

OpenAPI Resource Generator — Nx Workspace

Angular 22 · Nx monorepo · InjectionToken-based REST data access from OpenAPI specs via httpResource.

Implementation status: Three published packages — @constantant/openapi-resource-gen (v1.8.0), @constantant/openapi-resource-mocks (v0.5.0), Chrome Extension openapi-resource-mocks-devtools (v0.7.0). Data-access libs: github, petstore, weather, youtube — all generated with --includeMocks=true --specId=<name>. apps/api-explorer is wired up with routes and Angular Material UI. apps/devtools-panel is the Angular 22 panel app bundled inside the Chrome Extension.


Project goal

Build an Nx generator (openapi-resource-gen) that reads an OpenAPI 3.x spec and emits one InjectionToken per endpoint, each in its own .ts file, enabling maximum tree-shaking. Validate the approach with a demo Angular 22 app (api-explorer) that consumes multiple real-world APIs but ships only the endpoints it actually injects.


Workspace layout

apps/
  api-explorer/               # Angular 22 standalone app, zoneless, OnPush default
  devtools-panel/             # Angular 22 panel app — bundled inside the Chrome Extension
  devtools-panel-e2e/         # Playwright E2E tests for devtools-panel (port 4202)

libs/
  github-data-access/         # generated — github/rest-api-description
  petstore-data-access/       # generated — OAI petstore spec
  weather-data-access/        # generated — Open-Meteo forecast API
  youtube-data-access/        # generated — YouTube Data API v3 (76 endpoints)

tools/
  openapi-resource-gen/       # Nx plugin (generator + executor), published as @constantant/openapi-resource-gen
    src/
      generators/
        api-resource/
          schema.json
          generator.ts
          parse-spec.ts
          render-token.ts
          endpoint-model.ts
          files/              # EJS templates
            __tag__/
              __operationId__.token.ts__tmpl__
            api-base-url.token.ts__tmpl__
            index.ts__tmpl__
      executors/
        generate/
          schema.json
          executor.ts         # wraps the generator for nx run project:generate

  openapi-resource-mocks/     # published as @constantant/openapi-resource-mocks
                              # mock bus: window API, DOM events, Chrome Extension bridge
                              # src/testing.ts — /testing sub-entry (mockResource, MockResourceHandle)

  openapi-resource-devtools/  # Chrome Extension shell (manifest, content script, service worker,
                              # devtools page). Panel UI lives in apps/devtools-panel/.
    src/
      background/sw.ts        # service worker — routes messages between content scripts and panel
      content/content.ts      # content script — bridges window DOM events ↔ background SW
      devtools/devtools.ts    # creates the DevTools panel page
    manifest.json             # version source of truth for the extension
    CHANGELOG.md
    scripts/release.mjs       # standalone release script (bumps manifest, writes changelog, tags)

Angular 22 key APIs in use

  • httpResource() — stable (Angular 22). Reactive wrapper around HttpClient. Returns HttpResourceRef<T> with .value(), .isLoading(), .error() signals. The lambda re-runs whenever signals inside it change (like switchMap but declarative). Returns undefined from the lambda to suppress the request entirely (resource stays idle).
  • @Service() decorator — stable (Angular 22). Replaces @Injectable({ providedIn: 'root' }). Default behaviour is root-scoped singleton, tree-shakeable.
  • OnPush — now the default ChangeDetectionStrategy. New components get it automatically. Use ChangeDetectionStrategy.Eager only for legacy interop.
  • InjectionToken with factory — tree-shakeable when providedIn: 'root' and factory uses inject() internally. This is the core pattern of every generated token file.
  • Signal Forms (form() + FormField directive) — stable (Angular 22). Use for mutation pages.
  • Zoneless — default for new projects. No zone.js import needed.

Generated token pattern (canonical)

GET with query params (providedIn: 'none' — the default)

// libs/petstore-data-access/src/pet/find-pets-by-status.token.ts
import { InjectionToken, inject, FactoryProvider } from '@angular/core';
import { httpResource } from '@angular/common/http';
import type { paths } from '../schema.d';
import { PETSTORE_BASE_URL } from '../api-base-url.token';

export type FindPetsByStatusParams = paths['/pet/findByStatus']['get']['parameters']['query'];
export type FindPetsByStatusResponse = paths['/pet/findByStatus']['get']['responses']['200']['content']['application/json'];

export const FIND_PETS_BY_STATUS = new InjectionToken<(params?: FindPetsByStatusParams | (() => FindPetsByStatusParams | undefined)) => ReturnType<typeof httpResource<FindPetsByStatusResponse>>>('FIND_PETS_BY_STATUS');

export function provideFindPetsByStatus(): FactoryProvider {
  return {
    provide: FIND_PETS_BY_STATUS,
    useFactory: () => {
      const base = inject(PETSTORE_BASE_URL);
      return (params?) =>
        httpResource<FindPetsByStatusResponse>(() => {
          const _params = typeof params === 'function' ? params() : params;
          if (typeof params === 'function' && _params === undefined) return undefined;
          return {
            url: `${base}/pet/findByStatus`,
            params: _params as unknown as Record<string, string | number | boolean | readonly (string | number | boolean)[]>,
          };
        });
    },
  };
}

Rules:

  • Query params (any method) use a block-body lambda with _params pre-computation and early return undefined guard — this makes httpResource idle when the thunk returns undefined. Shorthand () => ({...}) would always fire because the lambda always returns an object.
  • inject() inside factory only — no constructor DI.
  • Types always sourced from paths[...]['get']['responses']['200'][...] — never hand-written.
  • Mutations (POST/PUT/PATCH/DELETE): factory returns (body: Signal<T> | T) => httpResource(...), add method: 'POST' (etc.) and body to the resource config. A Signal body is unwrapped inside the reactive lambda (_body), so it is sent as its value and re-fires when it changes.
  • Query params on mutations: a params argument after the body ((id, body, params?)). It is required when the spec has a required query param (YouTube's part); GET keeps its optional params?. Required args always precede optional ones.
  • Path params (e.g. /pets/{id}): become required args on the returned function, interpolated into the URL string inside the reactive lambda.
  • Header params (in: header, e.g. X-Api-Version): become named string args after path params. Required → plain arg: string; optional → arg?: string. Rendered into a headers object in the resource config; optional ones use a conditional spread (...(arg != null ? {...} : {})). Auth scheme headers appear alongside them in the same headers block.

Security tokens

When the spec has security schemes, the generator emits one additional file per scheme. Two different patterns are used depending on the scheme kind.

Signal-based (bearer, oauth2, openIdConnect, basic, apiKey-header, apiKey-query) — emits InjectionToken<Signal<string | null>>. Endpoint tokens inject these optionally and merge auth into the request headers/params:

// libs/youtube-data-access/src/oauth2.security-token.ts
import { InjectionToken, Signal } from '@angular/core';
export const OAUTH2 = new InjectionToken<Signal<string | null>>('OAUTH2');
const oauth2 = inject(OAUTH2, { optional: true }); // Signal<string | null> | null
// In the reactive lambda:
headers: {
  ...(oauth2?.() != null ? { Authorization: `Bearer ${oauth2()}` } : {}),
},

Interceptor-based (digest) — Digest is a challenge-response protocol (URL + method + nonce hash) that cannot be computed as a static header. The generator emits InjectionToken<HttpInterceptorFn> plus a named, host-scoped interceptor wrapper:

// libs/myapi-data-access/src/digest-auth.security-token.ts
import { InjectionToken, inject } from '@angular/core';
import { HttpInterceptorFn } from '@angular/common/http';
import { MYAPI_BASE_URL } from './api-base-url.token';

export const DIGEST_AUTH = new InjectionToken<HttpInterceptorFn>('DIGEST_AUTH');

export const myapiDigestAuthInterceptor: HttpInterceptorFn = (req, next) => {
  const base = inject(MYAPI_BASE_URL);
  if (!req.url.startsWith(base)) return next(req); // scoped to this API only
  const fn = inject(DIGEST_AUTH, { optional: true });
  if (!fn) return next(req);
  return fn(req, next); // req.urlWithParams, req.method, req.body available
};

The interceptor name is derived from the base URL token (MYAPI_BASE_URL → myapi) and the scheme name, making it unique per API. Multiple APIs with digest auth have distinct interceptors and distinct base URL checks — no cross-API conflicts. The consumer's implementation receives the full HttpRequest, which carries everything needed for the RFC 7616 hash.

Consumer wires it in app.config.ts:

provideHttpClient(withInterceptors([myapiDigestAuthInterceptor])),
{ provide: DIGEST_AUTH, useValue: myDigestInterceptorFn },

Generator implementation notes

Parsing pipeline

  1. If specPath is an http:///https:// URL, download to a temp file first (Node https/http).
  2. js-yaml + stripNonSchemaRefs() — load YAML, strip non-spec $ref links (markdown, images).
  3. Swagger 2.0 (swagger: '2.x') is converted to OpenAPI 3.0 in memory first (swagger2.ts: global produces/consumes pushed into operations, then @scalar/openapi-upgrader; opt out with convertSwagger2: false). Then validate: must be OpenAPI 3.x (openapi field starts with '3') and have a paths key. Throws a descriptive error otherwise.
  4. openapi-typescript (programmatic API) — emit schema.d.ts from the cleaned spec.
  5. @apidevtools/swagger-parser — dereference all $ref chains for endpoint extraction.
  6. parseSecuritySchemes(api) — extract security schemes into SecuritySchemeModel[].
  7. buildEndpoints(api, tags, convention) — map each operation to EndpointModel.
  8. renderTokenFile() / renderSecurityTokenFile() / renderMockFile() / renderMswFile() — emit token, mock, and MSW handler files as strings.
  9. Stale file cleanup — snapshot .token.ts, .security-token.ts, .mock.ts, .msw.ts, mocks.manifest.json, index.ts, index.mock.ts, and index.msw.ts files before the run; delete any that weren't produced this run.
  10. @nx/devkit formatFiles() — run Prettier over all written files.

Schema inputs (schema.json)

{
  "specPath": "local path OR https:// URL to the OpenAPI YAML/JSON file",
  "outputDir": "output directory inside libs/",
  "baseUrlToken": "optional: name of the base URL token (default: API_BASE_URL)",
  "tagFilter": "optional: only generate tokens for these tags",
  "namingConvention": "camel | kebab (default: kebab for filenames, SCREAMING_SNAKE for token names)",
  "providedIn": "none | root (default: none)",
  "includeMocks": "true | false (default: false) — emit *.mock.ts alongside each *.token.ts",
  "includeMswHandlers": "true | false (default: false) — emit *.msw.ts MSW 2.x handler files alongside each *.token.ts; adds /msw path alias to tsconfig.base.json",
  "clientType": "httpResource | httpClient (default: httpResource) — httpClient tokens return Observable<T> via HttpClient.request()",
  "callOptions": "true | false (default: false) — trailing per-call options argument (HttpContext, headers, withCredentials, httpResource defaultValue/equal/injector/debugName); emits request-options.ts",
  "convertSwagger2": "true | false (default: true) — convert Swagger 2.0 specs to OpenAPI 3.0 in memory (false rejects them)",
  "readWriteMarkers": "true | false (default: false) — honor readOnly/writeOnly: request bodies (Writable<>) drop readOnly properties, responses/errors (Readable<>) drop writeOnly ones, via openapi-typescript readWriteMarkers",
  "reportProgress": "true | false (default: false) — httpClient binary/multipart uploads + blob downloads yield Observable<HttpEvent<T>> (progress + response); httpResource blob downloads get reportProgress: true",
  "httpClientTags": "optional: comma-separated tags that use HttpClient regardless of clientType (errors if unmatched)",
  "httpClientOperations": "optional: comma-separated operationIds that use HttpClient regardless of clientType (errors if unmatched)",
  "validateResponses": "true | false (default: false) — validate JSON responses at runtime against the spec schema via httpResource's parse hook; requires @cfworker/json-schema",
  "specId": "string — embedded in MockResourceMeta._meta; must match the specId used when importing the spec into the DevTools panel",
  "verbose": "true | false (default: false) — print +/~/- summary of created/updated/deleted files after generation"
}

Tag → folder mapping

  • Each OpenAPI tag becomes one subfolder under outputDir.
  • Untagged operations go into default/.
  • Each folder gets its own index.ts barrel.
  • Root index.ts re-exports all folder barrels + security token files.

Demo app: api-explorer

Routes

Path Component Tokens injected Source lib
/ DashboardComponent GET_USER, LIST_REPOS, FIND_PETS_BY_STATUS, GET_V1_FORECAST multiple
/repos ReposPageComponent GET_USER, LIST_REPOS github-data-access
/pets PetsPageComponent FIND_PETS_BY_STATUS petstore-data-access
/weather WeatherPageComponent GET_V1_FORECAST weather-data-access
/youtube YoutubePageComponent YOUTUBE_SEARCH_LIST youtube-data-access

Angular Material usage

  • MatToolbar — top app bar
  • MatSidenav / MatNavList — navigation rail
  • MatCard — content panels
  • MatChipListbox / MatChip — status filter chips
  • MatTable + MatPaginator — data lists
  • MatProgressBar — loading indicator
  • MatFormField / MatInput — search and auth key input

OpenAPI specs used

Lib Spec source Endpoints
github-data-access github/rest-api-description — api.github.com.yaml ~38 used
petstore-data-access OAI Petstore v3 12
weather-data-access Open-Meteo forecast API ~5 used
youtube-data-access YouTube Data API v3 76 (all)

Fetch commands:

# GitHub
curl -L https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml \
  -o specs/github.yaml

# Petstore
curl -L https://petstore3.swagger.io/api/v3/openapi.yaml \
  -o specs/petstore.yaml

# YouTube Data API v3
curl -L https://raw.githubusercontent.com/APIs-guru/openapi-directory/main/APIs/googleapis.com/youtube/v3/openapi.yaml \
  -o specs/youtube.yaml

Common commands

# Development
npx nx serve api-explorer                  # Dev server on http://localhost:4200
npx nx serve devtools-panel               # Panel dev server on http://localhost:4200 (or specify port)

# Build
npx nx build api-explorer                  # Production build
npx nx build api-explorer --stats-json     # Include esbuild bundle stats
npx nx run openapi-resource-devtools:build # Build Chrome Extension → dist/tools/openapi-resource-devtools/

# Test
npx nx test api-explorer                   # Run unit tests (Vitest)
npx nx test devtools-panel                 # Run panel unit tests (Vitest)
npx nx e2e api-explorer-e2e               # Run Playwright E2E tests (port 4200)
npx nx e2e devtools-panel-e2e             # Run Playwright E2E tests (port 4202)
npx nx test openapi-resource-gen           # Run generator unit tests
npm run compat -- angular 21               # Compat matrix: libs + mocks on that Angular (20-min|20|21|22)
npm run compat -- nx 22                    # Compat matrix: generator on that Nx (20|21|22|23)

# Lint / format
npx nx run-many -t lint                    # Lint all projects
npx prettier --write apps/                 # Format code

# Type-check all
npx nx run-many -t typecheck

# Generate a data-access lib from a spec (with mocks for DevTools panel)
npx nx g @constantant/openapi-resource-gen:api-resource \
  --specPath=specs/youtube.yaml \
  --outputDir=libs/youtube-data-access/src \
  --baseUrlToken=YOUTUBE_BASE_URL \
  --includeMocks=true \
  --specId=youtube

# Inspect bundle after build with --stats-json
npx webpack-bundle-analyzer dist/apps/api-explorer/browser/stats.json

Nx workspace conventions

CRITICAL — always use Nx generators to scaffold new apps, libs, and components. Never hand-craft project.json, tsconfig.json, or angular.json files from scratch. Use the appropriate generator and then make minimal edits to the generated output only where strictly necessary. This keeps the workspace consistent and ensures all Nx cache inputs, lint rules, and build targets are wired up correctly.

Key generators for this workspace:

# New Angular app
npx nx g @nx/angular:application <name> --directory=apps/<name> --style=less --routing=false --standalone --no-interactive

# New Angular library
npx nx g @nx/angular:library <name> --directory=libs/<name> --standalone --no-interactive

# New standalone component inside an existing project
npx nx g @nx/angular:component <name> --project=<project> --standalone --no-interactive

# New service inside an existing project
npx nx g @nx/angular:service <name> --project=<project> --no-interactive

Coding conventions

CRITICAL — generated code is read-only. Files under libs/*/src/ are 100% machine-generated and must never be edited by hand. Any bug or missing feature in a generated file must be fixed in the generator (tools/openapi-resource-gen/) and then the affected lib must be regenerated with npx nx g @constantant/openapi-resource-gen:api-resource .... The node_modules/@constantant/openapi-resource-gen package is a Windows Junction that points directly at tools/openapi-resource-gen, so generator changes are live immediately — no publish step needed.

  • All new components: standalone, no NgModule, no zone.js.
  • Change detection: OnPush is the default — do NOT set it explicitly unless overriding.
  • Signals: prefer signal() + computed() over RxJS for local state.
  • httpResource for all HTTP reads. For mutations, use httpResource with method set.
  • No @Injectable services — ever. All shared state and behaviour must be expressed as InjectionToken with a factory (using inject() internally). This applies to everything that would classically be a service: state containers, bridge objects, utilities, etc. Use providedIn: 'root' on the token factory for singletons, or return a FactoryProvider function (e.g. provideXxx()) when the caller must opt in explicitly.
  • Template syntax: use @if, @for, @switch (Angular 17+ control flow). No *ngIf.
  • Imports: always import from the barrel index.ts of a lib, never from internal paths.
  • Do not add console.log to committed code.
  • Commit messages: feat:, fix:, chore:, docs: prefixes.

Chrome DevTools Extension

Architecture

page (Angular app with @constantant/openapi-resource-mocks)
  │  window.__openApiMocks__          (MockResourceBus)
  │  window.__oarmPendingCatch__      (pre-injected by SW at page-load start)
  │  DOM events: openapi-mock-event / openapi-mock-control
  ▼
content script (tools/openapi-resource-devtools/src/content/content.ts)
  │  chrome.runtime.sendMessage / chrome.runtime.onMessage
  ▼
background service worker (tools/openapi-resource-devtools/src/background/sw.ts)
  │  chrome.runtime.Port (named "devtools-<tabId>")
  │  tabCatchModes: Map<tabId, Record<key, boolean>>   (in-memory + chrome.storage.session)
  ▼
devtools panel (apps/devtools-panel/) — Angular 22 app
  MOCK_BRIDGE InjectionToken  ←→  port.postMessage / port.onMessage

The panel app (apps/devtools-panel/) is a standalone Angular 22 app that runs inside the Chrome DevTools panel page. It communicates with the inspected page via the background service worker using named ports.

Catch-mode pre-injection (SW → page at navigation start)

When a developer enables catch mode on any mock (including a local/unregistered one), the SW records it in an in-memory tabCatchModes map (mirrored to chrome.storage.session under oarm_catch_<tabId> for SW sleep survival). On chrome.tabs.onUpdated with status: 'loading', the SW immediately calls chrome.scripting.executeScript with injectImmediately: true to set window.__oarmPendingCatch__ = { KEY: true, ... } in the MAIN world — before Angular's <script type="module"> tags execute.

MockResourceBus constructor reads window.__oarmPendingCatch__ synchronously and pre-populates catchModeKeys. This ensures the very first _notifyRequest() call (which fires synchronously inside provideMockResource's factory during Angular bootstrap) is already intercepted, with no async round-trip required.

MOCK_BRIDGE token

apps/devtools-panel/src/app/mock-bridge.token.ts — the core DI bridge. Its factory:

  • When running inside Chrome DevTools (chrome.devtools is defined): creates a port, connects to the background SW, and returns a live MockBridge that drives the panel.
  • When running outside Chrome DevTools (dev server, E2E, Vitest): returns a no-op stub bridge. Guard: typeof chrome === 'undefined' || !chrome.devtools.

Never provide MOCK_BRIDGE via @Injectable — it must always be the token factory.

MockBridge interface includes two methods for panel-managed (local) mocks:

  • createLocalMock(key, meta) — adds an entry with status: 'local', persists to chrome.storage.local['oarm_local_mocks'] (schema: Record<string, { meta, catchMode }>), and auto-selects the new entry in the table.
  • deleteLocalMock(key) — removes the entry and clears it from storage. Guard: only deletes entries whose state.status === 'local', never live entries.

Local mocks are restored from storage on panel init (after connect()). When mock-keys arrives containing a key that was local, the entry is promoted in-place: status transitions to 'idle', catchMode is preserved, and the catch-mode re-send loop fires the setCatchMode control message to the newly-live bus. The entry is removed from oarm_local_mocks storage at that point.

DevTools panel tech stack

  • CodeMirror 6 — apps/devtools-panel/src/app/components/json-editor/ — syntax-highlighted JSON editor in the Respond tab. Uses VS Code Dark+ colours. Bidirectional sync with Angular signals via a skipSync flag.
  • json-schema-faker — ⚡ Generate button in Respond tab generates example JSON from the response schema.
  • @cfworker/json-schema — ✓ Validate validates the editor content against the response schema. Critical: the standalone validate() export is broken (always returns {valid: true}). Always use:
    const { Validator } = await import('@cfworker/json-schema');
    const result = new Validator(schema).validate(instance);
  • js-yaml — load as yamlLoad — Specs tab accepts .yaml/.yml files and YAML URLs in addition to JSON.
  • SPEC_STORE token (apps/devtools-panel/src/app/spec-store.token.ts) — stores OpenAPI specs in chrome.storage.local. Uses a module-level rewrittenDefsCache: Map<string, …> to defer the expensive rewriteRefs() call on large components/schemas blobs (e.g. github.yaml) until the first findSchema() call, avoiding hangs on import.
  • MockResourceMeta — { specId, operationId, path, method, tag? } — embedded in each generated mock file as export const _meta: MockResourceMeta. Passed to provideMockResource() so the panel can show operation info in the mock table and Respond tab.
  • CreateMockDialog — apps/devtools-panel/src/app/components/create-mock-dialog/ — opened by the "+ New mock" button in the mock table. Lets developers create panel-managed (local) mocks before provideMockResource() exists in the app: pick a spec from SPEC_STORE, select an operation, confirm the auto-generated key (toScreamingSnake(operationId), editable). The key is conflict-checked against MOCK_BRIDGE.mocks(). Uses MatDialog from @angular/material/dialog.
  • toScreamingSnake — exported from apps/devtools-panel/src/app/spec-store.token.ts. Used by CreateMockDialog to derive the default key from an operationId, matching the generator's naming convention exactly.
  • ScenarioDialog — apps/devtools-panel/src/app/components/scenario-dialog/ — opened by the "Scenarios" toolbar button. Saves/loads/deletes named mock state snapshots (values + catch modes) and exports/imports current state as JSON. Opened via MatDialog.open() — must NOT appear in the host component's imports[].
  • SCENARIO_STORE token — apps/devtools-panel/src/app/scenario-store.token.ts — providedIn: 'root' token. Persists named Scenario objects to chrome.storage.local['oarm_scenarios'] (keyed by name, sorted newest-first). load() calls bridge.sendControl + bridge.setCatchMode per mock entry; skips keys not in bridge.mocks() and skips items with invalid shape. importJson() applies state immediately without saving a named scenario.
  • History tab request inspector — apps/devtools-panel/src/app/components/history-tab/ — when expanding a request or caught event, displays: a METHOD /filled/path header (path params substituted from actual arg values using meta.path), individual path-param rows labeled by placeholder name, and remaining args labeled Query (GET/HEAD/DELETE) or Body (POST/PUT/PATCH). Binary placeholders ([FormData], [Blob], [ArrayBuffer], [File: name]) are rendered as inline badges rather than JSON strings. The row preview for request events also shows the filled URL. Gracefully falls back to a generic "Request" section when MockResourceMeta is null.

Releasing the extension

Releases go through .github/workflows/release-extension.yml (triggered manually via gh workflow run release-extension.yml). Required GitHub secrets:

Secret Purpose
GH_PAT Admin PAT — bypasses branch protection to push version bump commit + tag
CHROME_EXTENSION_ID CWS extension ID
CHROME_PUBLISHER_ID Publisher account ID (from CWS developer console URL)
CHROME_CLIENT_ID OAuth2 client ID
CHROME_CLIENT_SECRET OAuth2 client secret
CHROME_REFRESH_TOKEN OAuth2 refresh token

The release script (tools/openapi-resource-devtools/scripts/release.mjs) bumps manifest.json, writes CHANGELOG.md, creates an annotated git tag (git tag -a openapi-resource-devtools@<version>). The workflow then pushes the tag explicitly (git push origin "refs/tags/<tag>") — git push --follow-tags skips lightweight tags, so explicit push is required.


Repository & contribution governance

This is a public, MIT-licensed repo open to outside contributions. Community health files live at the root and under .github/:

  • LICENSE, CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md
  • .github/CODEOWNERS (* @constantant), .github/dependabot.yml
  • .github/pull_request_template.md, .github/ISSUE_TEMPLATE/* (YAML forms)

master is branch-protected — no direct pushes; all changes go via PR:

  • the CI main job must pass,
  • 1 approving code-owner review is required,
  • linear history (squash/rebase merges only — no merge commits),
  • enforce_admins is off so the release workflow's GITHUB_TOKEN can still push the version bump commit + tag during nx release.

When making changes here, branch off master and open a PR; use a Conventional Commits PR title (it becomes the squash commit). See CONTRIBUTING.md for the full workflow.


Key decisions

Decision Choice Reason
Token granularity One file per endpoint Enables file-level tree-shaking by esbuild
Type source openapi-typescript paths type Zero runtime, fully typed, no codegen bloat
HTTP primitive httpResource (stable, Angular 22) Signal-native, auto-cancels stale requests
Mutation pattern Factory returns (body) => httpResource(...) Consistent API surface for GET and mutations
Base URL injection Named InjectionToken<string> per lib Lets apps override URL per environment
Params type Typed via paths[...]['parameters']['query'] Full type safety, no manual interfaces
Request suppression Block-body lambda with early return undefined Shorthand () => ({url}) always fires; returning undefined from lambda makes resource idle
Security tokens — signal `InjectionToken<Signal<string null>>` per scheme
Security tokens — digest InjectionToken<HttpInterceptorFn> + named host-scoped interceptor Challenge-response at HTTP layer; base URL token prevents cross-API interceptor conflicts
Remote spec URL specPath accepts http:///https:// URLs — downloads to a temp file, then processes identically to local files Eliminates the curl pre-step; temp file is cleaned up in the finally block regardless of success/failure
Header params in: header params become named string args (required or optional), rendered into a headers block alongside auth scheme headers Consistent with how path params are surfaced; keeps the public API surface explicit and typed
Cookie params in: cookie params become named string args (after header params) and are combined into a single Cookie header using [...].join('; '); optional cookies use a conditional spread Cookie params work in SSR (Node HttpClient); browser Cookie header is a forbidden header — document that constraint at usage time rather than in the type
@deprecated JSDoc Generator emits /** @deprecated */ above the InjectionToken constant when operation.deprecated === true in the spec TypeScript deprecation warning at the inject() call site; no runtime cost
Response type unions Generator collects all 2xx codes with application/json content (not just the first); emits a |-union type alias when multiple codes exist Some endpoints legitimately return 200 (update) or 201 (create) with different shapes; a union preserves that information instead of silently picking one
Binary body When requestBody has no json/form/multipart content type (e.g. application/octet-stream, image/*), the generated Body type alias is Blob | ArrayBuffer instead of the paths chain paths[...]['requestBody']['content']['application/octet-stream'] would be string | Blob from openapi-typescript — not useful for Angular's HttpClient which needs the actual binary object
Stale file cleanup Before generation, snapshot .token.ts, .security-token.ts, .mock.ts, mocks.manifest.json, index.ts, and index.mock.ts files; after generation, delete any that weren't produced this run Prevents phantom exports when endpoints are removed or tagFilter narrows the output; including barrel files ensures orphaned tag folders (with their index.ts) are also removed
Nx executor @constantant/openapi-resource-gen:generate executor wraps the generator so users can declare a generate target in project.json nx run mylib:generate is easier to remember and can be wired into CI; uses FsTree+flushChanges from nx/src/generators/tree (not in @nx/devkit public API)
Lint cache invalidation @nx/eslint:lint has an externalDependencies input listing the ESLint plugin packages (eslint, angular-eslint, typescript-eslint, @eslint/js, …) in nx.json A rule-strengthening dependency bump (e.g. an angular-eslint major) must re-lint against current source, not return a stale cached "pass". Without this, the angular-eslint 22 upgrade merged green while leaving master failing prefer-on-push-component-change-detection
includeMocks + MockResourceMeta Generator emits *.mock.ts alongside each *.token.ts; each mock file exports _meta: MockResourceMeta with specId, operationId, path, method, tag DevTools panel needs this metadata to display operation info and match mock keys to spec entries
Lazy definitions cache in SPEC_STORE rewrittenDefsCache: Map<string, Record<string, unknown>> defers rewriteRefs() on components/schemas to first findSchema() call; separate rawDefinitions field in SpecEntry avoids embedding the full definitions blob into every per-operation schema github.yaml has ~30MB of definitions; embedding them N times in storage caused hangs. Deferring to first use keeps import fast
@cfworker/json-schema Validator class Use new Validator(schema).validate(instance), NOT the standalone validate() function The standalone validate export is broken — always returns {valid: true}. The Validator class API works correctly and resolves $ref/definitions
Respond tab pinned footer schema-section, delay-row, and action-row moved out of the scrollable .respond-body into a flex-shrink: 0 .respond-footer below it When the editor grows tall, schema/delay/actions were scrolled off-screen. Pinning them ensures they are always reachable
Local (unregistered) mocks MockBridge.createLocalMock() adds a panel-managed entry with status: 'local'; persisted in chrome.storage.local['oarm_local_mocks'] with catchMode; promoted in-place when the app registers the key Developer pre-configures a mock (catch mode, Respond tab value) before writing the Angular code. All controls work identically to live mocks; control messages to the page are silently ignored until the key is registered
Catch-mode pre-injection SW tracks catch modes in memory + chrome.storage.session['oarm_catch_<tabId>']; on tabs.onUpdated(loading) injects window.__oarmPendingCatch__ via executeScript(injectImmediately:true); MockResourceBus constructor reads and pre-populates catchModeKeys provideMockResource calls _notifyRequest() synchronously during Angular bootstrap — before any async panel message can arrive. Pre-injection closes this race so the very first request is caught
includeMswHandlers Generator emits *.msw.ts alongside each *.token.ts; each file exports listPetsHandler(body?) + listPetsHandlers array; path params converted {id} → :id; DELETE/no-response endpoints use new HttpResponse(null, { status: 204 }); root index.msw.ts barrel + /msw tsconfig alias added automatically MSW 2.x is the standard for component-test mocking; this lets consumers drop handlers directly into server.use(listPetsHandlers) without writing boilerplate
clientType / HttpClient tokens Per-endpoint choice (lib default + httpClientTags / httpClientOperations overrides). httpClient tokens return (args) => Observable<T> using http.request(METHOD, url, opts); params/body are plain values; validation runs via map(). Mock files use provideMockObservable() (cold: each subscribe = one request, settles via _onSettle on the ref; a panel resolve/fail with nothing pending is stored per key and replayed by the next request). Demo: weather and youtube fully, petstore (store tag) and github (users tag) mixed, consumed via rxResource — whose value() throws in error state, so guard with hasValue() Some consumers want RxJS/interceptor-friendly cold calls instead of signal resources; one token pattern per endpoint keeps tree-shaking and the mock/DevTools flow intact
Per-call options Opt-in callOptions: a trailing options argument. httpResource tokens get a generic XxxFn so a defaultValue narrows value() to T; httpClient tokens take CallOptions. Types derive from Angular's HttpResourceRequest/HttpResourceOptions (so they match the installed version) and splitCallOptions routes fields by key list, never naming debugName in code. Spec-controlled keys are dropped at runtime. Mocks record _meta.args Spec-driven requests can't carry an HttpContext, extra headers or a defaultValue. Version-derived types keep Angular 20.0.0 (no debugName) compiling; the mock honours defaultValue so mock mode matches the real resource. petstore-data-access is generated with it
Swagger 2.0 input Swagger 2.0 input is converted in memory by swagger2.ts (@scalar/openapi-upgrader, loaded via require() with a dynamic-import() fallback because it is ESM-only). Global produces/consumes are first pushed down into operations; convertSwagger2: false rejects Swagger 2.0 instead The upgrader applies a global produces over an operation's own, which turned image/png downloads into JSON (generator picked the wrong variant). Verified on the real Petstore v2, a compiled integration spec, and Node 20.18 and 24
readWriteMarkers Opt-in readWriteMarkers: calls openapi-typescript with { readWriteMarkers: true } (adds $Read/$Write/Readable/Writable to schema.d.ts), then wraps XxxBody in Writable<> and XxxResponse / XxxError / discriminated-variant types in Readable<> (inside Readonly<> with --readonlyResponses). Binary bodies and text/blob responses are left alone Without it one type serves both directions, so a request body requires server-generated ids and a response claims write-only fields (passwords). Verified by compiling real generator output with @ts-expect-error probes; no measurable tsc cost on the 5 MB GitHub schema
Compatibility floor Generated libs and openapi-resource-mocks: Angular ≥ 20; openapi-resource-gen: Nx ≥ 20. Enforced by .github/workflows/compat.yml → tools/compat/run.mjs (a throwaway workspace per version: Angular 20.0.0 and 20.x–22.x, Nx 20–23; strict tsc + wire-level tests, or the generator suite + a real end-to-end generation). Peer ranges match the matrix The old >=22 was declared, not required: generated code only uses APIs present since Angular 19.2. 20 is the floor because the mock Resource mirrors ResourceStatus as strings (a numeric enum in 19) and 19 is out of support. Upload progress needs XHR: default on 20/21, withXhr() on 22+
reportProgress Opt-in. httpClient upload/blob-download endpoints yield Observable<HttpEvent<T>> (observe: 'events', reportProgress: true); validation maps only the Response event. Mock files use provideMockHttpEvents() (emits Sent, UploadProgress/DownloadProgress from setProgress/simulateProgress, then Response). httpResource only gets reportProgress: true on blob downloads Angular 22's httpResource().progress() handles DownloadProgress only and ignores uploads, so a real upload progress bar needs the HttpClient flavour. Upload progress also needs provideHttpClient(withXhr()) (the fetch backend can't report it); api-explorer uses withXhr(), and its Pets page shows it
/testing entry point @constantant/openapi-resource-mocks/testing exports mockResource(TOKEN, behavior?) returning MockResourceHandle<T>, plus mockObservable / mockHttpEvents for httpClient tokens (cold: each subscription consumes the next behavior; mockHttpEvents also emits Sent + progress events) — a FactoryProvider plus .ref, .calls, .expectCalled(), .expectCalledWith(). Supports { sequence: [...] } for multi-step scenarios. No MockResourceBus, no DOM events — pure signal state Vitest/Jasmine component tests need zero-infrastructure mocking; sequence mode enables pagination, retry, and error-then-success patterns in a single test
Scenario save/load SCENARIO_STORE token (providedIn: 'root') persists named Scenario objects to chrome.storage.local['oarm_scenarios']; load() applies via bridge.sendControl + bridge.setCatchMode; exportJson() serialises current mock table to JSON; importJson() applies without saving Developers can commit scenario files alongside feature branches and restore a full mock state in one click, or share via JSON export
History tab request inspector payloadSections(ev, meta) extracts path-param names from meta.path {placeholders}, fills in actual arg values to produce GET /pet/42 header line, labels remaining args as Query or Body based on HTTP method, renders [FormData]/[Blob] as badges instead of JSON strings Raw JSON.stringify(args) was unreadable for multi-arg calls; path-template parsing gives named labels without any runtime schema
validateResponses Opt-in generator flag; embeds the dereferenced response JSON Schema per endpoint and wires a _validateResponse() function through httpResource's parse hook (using @cfworker/json-schema's Validator class, same as the DevTools panel). OAS nullable: true is rewritten to a type array before embedding. Endpoints with a circular $ref in their response schema are skipped (not thrown) — JSON.stringify/recursive-walk failure is caught and treated as "no validation for this endpoint" Compile-time types vanish at runtime; a spec/server drift on a third-party API otherwise surfaces as an unhandled TypeError deep in a component instead of at the httpResource boundary. Kept opt-in because embedding a schema per endpoint has a bundle-size cost

Reference links

General Guidelines for working with Nx

  • For navigating/exploring the workspace, invoke the nx-workspace skill first - it has patterns for querying projects, targets, and dependencies
  • When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through nx (i.e. nx run, nx run-many, nx affected) instead of using the underlying tooling directly
  • Prefix nx commands with the workspace's package manager (e.g., pnpm nx build, npm exec nx test) - avoids using globally installed CLI
  • You have access to the Nx MCP server and its tools, use them to help the user
  • For Nx plugin best practices, check node_modules/@nx/<plugin>/PLUGIN.md. Not all plugins have this file - proceed without it if unavailable.
  • NEVER guess CLI flags - always check nx_docs or --help first when unsure

Scaffolding & Generators

  • For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the nx-generate skill FIRST before exploring or calling MCP tools

When to use nx_docs

  • USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases
  • DON'T USE for: basic generator syntax (nx g @nx/react:app), standard commands, things you already know
  • The nx-generate skill handles generator discovery internally - don't call nx_docs just to look up generator syntax