@counterfact/generator 0.1.1 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/dist/openapi-path.js +27 -0
- package/dist/repository.js +26 -2
- package/package.json +3 -3
- package/src/README.md +128 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# @counterfact/generator
|
|
2
|
+
|
|
3
|
+
## 0.1.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [99dab55]
|
|
8
|
+
- @counterfact/openapi@0.1.4
|
|
9
|
+
|
|
10
|
+
## 0.1.3
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- 5e4b9eb: Keep every generated repository file contained within its destination directory.
|
|
15
|
+
- 28aafab: Reject OpenAPI route paths that cannot be represented safely in generated output.
|
|
16
|
+
- Updated dependencies [28aafab]
|
|
17
|
+
- @counterfact/openapi@0.1.3
|
|
18
|
+
|
|
19
|
+
## 0.1.2
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- Updated dependencies [669d9dd]
|
|
24
|
+
- @counterfact/openapi@0.1.2
|
|
25
|
+
|
|
26
|
+
## 0.1.1
|
|
27
|
+
|
|
28
|
+
### Patch Changes
|
|
29
|
+
|
|
30
|
+
- Updated dependencies [f5e437d]
|
|
31
|
+
- @counterfact/openapi@0.1.1
|
package/dist/openapi-path.js
CHANGED
|
@@ -7,12 +7,39 @@
|
|
|
7
7
|
export function normalizeOpenApiPath(openApiPath) {
|
|
8
8
|
return openApiPath.replace(/\/+$/u, "") || "/";
|
|
9
9
|
}
|
|
10
|
+
function assertValidOpenApiPath(openApiPath) {
|
|
11
|
+
const normalizedPath = normalizeOpenApiPath(openApiPath);
|
|
12
|
+
const invalidReason = invalidOpenApiPathReason(normalizedPath);
|
|
13
|
+
if (invalidReason !== undefined) {
|
|
14
|
+
throw new Error(`Invalid OpenAPI path ${JSON.stringify(openApiPath)}: ${invalidReason}.`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function invalidOpenApiPathReason(openApiPath) {
|
|
18
|
+
if (!openApiPath.startsWith("/")) {
|
|
19
|
+
return "paths must begin with a forward slash";
|
|
20
|
+
}
|
|
21
|
+
if (openApiPath.includes("\0")) {
|
|
22
|
+
return "paths must not contain NUL characters";
|
|
23
|
+
}
|
|
24
|
+
if (openApiPath.includes("\\")) {
|
|
25
|
+
return "paths must use forward slashes only";
|
|
26
|
+
}
|
|
27
|
+
const segments = openApiPath.split("/").slice(1);
|
|
28
|
+
if (openApiPath !== "/" && segments.some((segment) => segment === "")) {
|
|
29
|
+
return "paths must not contain empty internal segments";
|
|
30
|
+
}
|
|
31
|
+
if (segments.some((segment) => segment === "." || segment === "..")) {
|
|
32
|
+
return 'paths must not contain "." or ".." segments';
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
10
36
|
/**
|
|
11
37
|
* Rejects path keys that would target the same generated route module.
|
|
12
38
|
*/
|
|
13
39
|
export function assertNoNormalizedPathCollisions(openApiPaths) {
|
|
14
40
|
const originalPathByNormalizedPath = new Map();
|
|
15
41
|
for (const openApiPath of openApiPaths) {
|
|
42
|
+
assertValidOpenApiPath(openApiPath);
|
|
16
43
|
const normalizedPath = normalizeOpenApiPath(openApiPath);
|
|
17
44
|
const existingPath = originalPathByNormalizedPath.get(normalizedPath);
|
|
18
45
|
if (existingPath !== undefined && existingPath !== openApiPath) {
|
package/dist/repository.js
CHANGED
|
@@ -5,13 +5,36 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
/* eslint-disable security/detect-non-literal-fs-filename -- repository writes and stats generated files only inside destination output directories. */
|
|
6
6
|
import createDebug from "debug";
|
|
7
7
|
import { ensureDirectoryExists } from "./ensure-directory-exists.js";
|
|
8
|
-
import { toForwardSlashPath,
|
|
8
|
+
import { toForwardSlashPath, pathRelative, pathDirname, } from "./forward-slash-path.js";
|
|
9
9
|
import { CONTEXT_FILE_TOKEN } from "./context-file-token.js";
|
|
10
10
|
import { Script } from "./script.js";
|
|
11
11
|
import { escapePathForWindows } from "./windows-escape.js";
|
|
12
12
|
const debug = createDebug("counterfact:server:repository");
|
|
13
13
|
const __dirname = toForwardSlashPath(dirname(fileURLToPath(import.meta.url)));
|
|
14
14
|
debug("dirname is %s", __dirname);
|
|
15
|
+
function assertSafeRepositoryPath(path) {
|
|
16
|
+
const segments = path.split("/");
|
|
17
|
+
if (path === "" ||
|
|
18
|
+
path.includes("\0") ||
|
|
19
|
+
path.includes("\\") ||
|
|
20
|
+
nodePath.posix.isAbsolute(path) ||
|
|
21
|
+
nodePath.win32.isAbsolute(path) ||
|
|
22
|
+
segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
|
23
|
+
throw new Error(`Repository path ${JSON.stringify(path)} must be a safe, relative, forward-slash path.`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function resolveDestinationPath(destination, path) {
|
|
27
|
+
const destinationRoot = nodePath.resolve(destination);
|
|
28
|
+
const candidatePath = nodePath.resolve(destinationRoot, path);
|
|
29
|
+
const relativePath = nodePath.relative(destinationRoot, candidatePath);
|
|
30
|
+
if (relativePath === ".." ||
|
|
31
|
+
relativePath.startsWith(`..${nodePath.sep}`) ||
|
|
32
|
+
nodePath.isAbsolute(relativePath)) {
|
|
33
|
+
throw new Error(`Repository path ${JSON.stringify(path)} escapes destination ${JSON.stringify(destinationRoot)}.`);
|
|
34
|
+
}
|
|
35
|
+
assertSafeRepositoryPath(path);
|
|
36
|
+
return escapePathForWindows(candidatePath);
|
|
37
|
+
}
|
|
15
38
|
/**
|
|
16
39
|
* Collection of {@link Script} objects keyed by their repository-relative
|
|
17
40
|
* path.
|
|
@@ -34,6 +57,7 @@ export class Repository {
|
|
|
34
57
|
*/
|
|
35
58
|
get(path) {
|
|
36
59
|
debug("getting script at %s", path);
|
|
60
|
+
assertSafeRepositoryPath(path);
|
|
37
61
|
if (this.scripts.has(path)) {
|
|
38
62
|
debug("already have script %s, returning it", path);
|
|
39
63
|
return this.scripts.get(path);
|
|
@@ -88,8 +112,8 @@ export class Repository {
|
|
|
88
112
|
await this.finished();
|
|
89
113
|
debug("all %i scripts are finished", this.scripts.size);
|
|
90
114
|
const writeFiles = Array.from(this.scripts.entries(), async ([path, script]) => {
|
|
115
|
+
const fullPath = resolveDestinationPath(destination, path);
|
|
91
116
|
const contents = await script.contents();
|
|
92
|
-
const fullPath = escapePathForWindows(pathJoin(destination, path));
|
|
93
117
|
await ensureDirectoryExists(fullPath);
|
|
94
118
|
const shouldWriteRoutes = routes && path.startsWith("routes");
|
|
95
119
|
const shouldWriteTypes = types && !path.startsWith("routes");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@counterfact/generator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Generate Counterfact route scaffolds and OpenAPI-derived TypeScript contracts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -52,10 +52,10 @@
|
|
|
52
52
|
"test:packed-consumer": "node test/package/generator-consumer-smoke.mjs"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@counterfact/openapi": "0.1.
|
|
55
|
+
"@counterfact/openapi": "0.1.4",
|
|
56
56
|
"@counterfact/types": "0.1.0",
|
|
57
57
|
"chokidar": "5.0.0",
|
|
58
58
|
"debug": "4.4.3",
|
|
59
59
|
"prettier": "3.9.6"
|
|
60
60
|
}
|
|
61
|
-
}
|
|
61
|
+
}
|
package/src/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Generator internals
|
|
2
|
+
|
|
3
|
+
The files in this directory implement a code generator that takes an OpenAPI spec as input and translates it into TypeScript code. That TypeScript code includes types corresponding to each of the models ("components" or "schemas" in OpenAPI parlance). It also scaffolds out an implementation of the spec in the form of TypeScript files that are read by Counterfact.
|
|
4
|
+
|
|
5
|
+
A spec doesn't have enough information to build a _real_ implementation — it only describes the interfaces — but we use whatever information is available to get as close as we can to a full implementation. When the specification has example responses, it will randomly select an example and return it. Otherwise it will generate a structurally valid albeit nonsensical response.
|
|
6
|
+
|
|
7
|
+
The idea is to generate as much code as we possibly can and then edit the code to fill in the details.
|
|
8
|
+
|
|
9
|
+
Fortunately, we _do_ have pretty much all the information we need to generate _types_. Once that code is generated, there's no reason to touch it manually. If the spec changes, we can rerun the generator. It will recreate the types _only_. We can then leverage the type system to figure out what parts of our manually edited code need to be updated.
|
|
10
|
+
|
|
11
|
+
We can also use those types on the _client_ side, assuming the client is written in TypeScript.
|
|
12
|
+
|
|
13
|
+
## Files
|
|
14
|
+
|
|
15
|
+
| File | Description |
|
|
16
|
+
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
17
|
+
| `code-generator.ts` | Top-level `CodeGenerator` class; orchestrates the full generate pipeline (reads the OpenAPI spec, iterates over paths/operations, drives the `Repository` to write output files) and file watching via `EventTarget` |
|
|
18
|
+
| `specification.ts` | `Specification` class: loads and parses an OpenAPI document and provides cached `Requirement` lookup via JSON Pointer |
|
|
19
|
+
| `requirement.ts` | `Requirement` class: wraps a single OpenAPI schema object with its URL and resolves `$ref` pointers |
|
|
20
|
+
| `repository.ts` | `Repository` class: manages all output `Script` instances, deduplicates them, and coordinates async export resolution |
|
|
21
|
+
| `script.ts` | `Script` class: manages code generation for a single output file — imports, exports, deduplication, and Prettier formatting |
|
|
22
|
+
| `coder.ts` | Abstract `Coder` base class: defines the Template Method pattern used by all code-generating components |
|
|
23
|
+
| `type-coder.ts` | Abstract `TypeCoder` base class (extends `Coder`): specialises `Coder` for type-generating components |
|
|
24
|
+
| `operation-coder.ts` | `OperationCoder`: generates the route handler function for an OpenAPI operation |
|
|
25
|
+
| `operation-type-coder.ts` | `OperationTypeCoder`: generates the TypeScript type for a route handler, including parameters and response builder |
|
|
26
|
+
| `parameters-type-coder.ts` | `ParametersTypeCoder`: generates the typed object for path/query/header parameters of an operation |
|
|
27
|
+
| `parameter-export-type-coder.ts` | `ParameterExportTypeCoder`: generates and exports the type for a single request parameter |
|
|
28
|
+
| `responses-type-coder.ts` | `ResponsesTypeCoder`: generates the response builder factory type covering all HTTP status codes for an operation |
|
|
29
|
+
| `response-type-coder.ts` | `ResponseTypeCoder`: generates the type for a single HTTP response, including headers, content, and named examples |
|
|
30
|
+
| `schema-type-coder.ts` | `SchemaTypeCoder`: converts an OpenAPI schema definition to a TypeScript type (objects, arrays, unions, enums) |
|
|
31
|
+
| `schema-coder.ts` | `SchemaCoder`: generates a JSON Schema representation of an OpenAPI schema for use in runtime validation |
|
|
32
|
+
| `context-file-token.ts` | Exports a placeholder token used to reference context file paths during code generation |
|
|
33
|
+
| `printers.ts` | Utility functions for formatting TypeScript object literals in generated code |
|
|
34
|
+
| `read-only-comments.ts` | Standard warning comments inserted into generated type files to discourage manual edits |
|
|
35
|
+
|
|
36
|
+
## Architecture
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
openapi.yaml
|
|
40
|
+
│
|
|
41
|
+
▼
|
|
42
|
+
┌─────────────────┐
|
|
43
|
+
│ Specification │ Loads & parses the OpenAPI document
|
|
44
|
+
│ + Requirement │ Provides JSON-Pointer-based object lookup
|
|
45
|
+
└────────┬────────┘
|
|
46
|
+
│ Requirements
|
|
47
|
+
▼
|
|
48
|
+
┌──────────────────────────────────────────────────────┐
|
|
49
|
+
│ Coders │
|
|
50
|
+
│ │
|
|
51
|
+
│ OperationCoder ──▶ OperationTypeCoder │
|
|
52
|
+
│ │ │ │
|
|
53
|
+
│ │ ParametersTypeCoder │
|
|
54
|
+
│ │ ResponsesTypeCoder │
|
|
55
|
+
│ │ │ │
|
|
56
|
+
│ │ SchemaTypeCoder / SchemaCoder │
|
|
57
|
+
│ │ │
|
|
58
|
+
│ └──▶ Script (one per output file) │
|
|
59
|
+
└──────────────────┬───────────────────────────────────┘
|
|
60
|
+
│ Scripts
|
|
61
|
+
▼
|
|
62
|
+
┌────────────────┐
|
|
63
|
+
│ Repository │ Deduplicates and writes output files
|
|
64
|
+
└────────────────┘
|
|
65
|
+
│
|
|
66
|
+
┌────────▼────────────────────────┐
|
|
67
|
+
│ routes/hello-world.ts │ (route handler scaffold)
|
|
68
|
+
│ types/paths/hello-world.ts │ (typed interfaces)
|
|
69
|
+
│ components/schemas/Message.ts │ (schema types)
|
|
70
|
+
└─────────────────────────────────┘
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
A **Specification** is a list of **Requirements** encoded in an OpenAPI file.
|
|
74
|
+
|
|
75
|
+
A **Repository** is a set of **Scripts** that will be output.
|
|
76
|
+
|
|
77
|
+
A **Coder** is a command object that reads a particular type of requirement from the specification and turns it into code. Coders collaborate. A coder may do all of the work itself, but most of the time it will split up the requirement into smaller pieces and recruit other coders to help.
|
|
78
|
+
|
|
79
|
+
A **Specification** encapsulates an openapi.yaml file and all of the files that it references (and all of the files that they reference...). We reference individual objects within the specification via [JSON pointer](https://datatracker.ietf.org/doc/html/rfc6901) URLs.
|
|
80
|
+
|
|
81
|
+
How does this work? Let's explore by looking at a very simple OpenAPI Spec:
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
openapi: 3.0.3
|
|
85
|
+
info:
|
|
86
|
+
version: 1.0.0
|
|
87
|
+
title: Sample API
|
|
88
|
+
description: A sample API to illustrate OpenAPI concepts
|
|
89
|
+
paths:
|
|
90
|
+
/hello-world:
|
|
91
|
+
get:
|
|
92
|
+
description: hello world
|
|
93
|
+
responses:
|
|
94
|
+
default:
|
|
95
|
+
description: Successful response
|
|
96
|
+
content:
|
|
97
|
+
application/json:
|
|
98
|
+
schema:
|
|
99
|
+
type:
|
|
100
|
+
$ref: /Components/schemas/Message
|
|
101
|
+
examples:
|
|
102
|
+
no visits:
|
|
103
|
+
value:
|
|
104
|
+
greeting: Hello
|
|
105
|
+
object: World
|
|
106
|
+
components:
|
|
107
|
+
schemas:
|
|
108
|
+
Message:
|
|
109
|
+
schema:
|
|
110
|
+
type: object
|
|
111
|
+
properties:
|
|
112
|
+
greeting:
|
|
113
|
+
type: string
|
|
114
|
+
object:
|
|
115
|
+
type: string
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Our goal is to produce a file at `/routes/hello.ts` that exports a function named `GET` (1). Our implementation will depend on a type for the `GET` function which lives in `/routes/types-hello.ts` (2). That type, in turn, will depend on a type named `Message` which lives in `/components/message.ts` (3).
|
|
119
|
+
|
|
120
|
+
(1) We kick off the process by iterating over the paths. (In our simple example there's only one path, at `/hello`.) In our model, each path is represented as a `Requirement`. We give each path / `Requirement` to an `OperationCoder` who will write the code. We ask the repository for a `Script` and then hand the `Script` our `OperationCoder` instance and ask it to create an export.
|
|
121
|
+
|
|
122
|
+
(2a) The `OperationCoder` knows that the function has to have a type, but it doesn't know to create that type. So it recruits an `OperationTypeCoder` and hands it the `Requirement`. It then asks the `Script` to _import_ a type, passing along the `OperationTypeCoder`. The `Script` returns the name of the variable to which the imported type will be assigned. That name is all the `OperationCoder` needs to know to continue writing its portion of the code. The `OperationCoder` continues on, writing the `GET` function. Depending on what it finds in the `Requirement`, it will break off parts and delegate some of the work to other `Coder`s.
|
|
123
|
+
|
|
124
|
+
(2b) The `Script` has promised that it will import the type from `/types/paths/hello.type.ts` so it needs to make sure that file exists and has a matching export. It goes to the repository to get another `Script` and asks it to _export_ the type, passing along the `OperationTypeCoder` in the process. It's a little tricky here because `OperationTypeCoder`'s `Requirement` may not have the information it needs to proceed. It might have a `$ref` pointer to some _other_ requirement that might be in a different file. So before asking for the export, it asks the `OperationTypeCoder` to give it _another_ `OperationTypeCoder` that definitely has the requirement. Because the other requirement may be in another file that the `Repository` hasn't loaded yet, this part happens asynchronously.
|
|
125
|
+
|
|
126
|
+
(2c) When it's ready, the `Script` asks the `OperationTypeCoder` which definitely has an immediately usable requirement to write the export for the `Script` at `/types/paths/hello.types.ts`.
|
|
127
|
+
|
|
128
|
+
(3) The `OperationTypeCoder` needs the help of a `SchemaCoder` so it asks the `Script` at `/types/paths/hello.types.ts` for an export...
|