Skip to content

Commit 5210a57

Browse files
authored
Merge pull request #870 from BitGo/DX-658
feat: support custom codec files in host repositories
2 parents aa52270 + 348be34 commit 5210a57

File tree

10 files changed

+306
-39
lines changed

10 files changed

+306
-39
lines changed

packages/openapi-generator/README.md

Lines changed: 123 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ API specification into an OpenAPI specification.
55

66
## Install
77

8-
```
8+
```shell
99
npm install --save-dev @api-ts/openapi-generator
1010
```
1111

@@ -15,7 +15,7 @@ The **openapi-generator** assumes the io-ts-http `apiSpec` is exported in the to
1515
of the Typescript file passed as an input parameter. The OpenAPI specification will be
1616
written to stdout.
1717

18-
```
18+
```shell
1919
ARGUMENTS:
2020
<file> - API route definition file
2121

@@ -35,32 +35,125 @@ For example:
3535
npx openapi-generator src/index.ts
3636
```
3737

38-
## Custom codec file
39-
40-
`openapi-generator` only reads files in the specified package, and stops at the module
41-
boundary. This allows it to work even without `node_modules` installed. It has built-in
42-
support for `io-ts`, `io-ts-types`, and `@api-ts/io-ts-http` imports. If your package
43-
imports codecs from another external library, then you will have to define them in a
44-
custom configuration file so that `openapi-generator` will understand them. To do so,
45-
create a JS file with the following format:
46-
47-
```typescript
48-
module.exports = (E) => {
49-
return {
50-
'io-ts-bigint': {
51-
BigIntFromString: () => E.right({ type: 'string' }),
52-
NonZeroBigInt: () => E.right({ type: 'number' }),
53-
NonZeroBigIntFromString: () => E.right({ type: 'string' }),
54-
NegativeBigIntFromString: () => E.right({ type: 'string' }),
55-
NonNegativeBigIntFromString: () => E.right({ type: 'string' }),
56-
PositiveBigIntFromString: () => E.right({ type: 'string' }),
57-
},
58-
// ... and so on for other packages
59-
};
60-
};
61-
```
38+
## Preparing a types package for reusable codecs
39+
40+
In order to use types from external `io-ts` types packages, you must ensure two things
41+
are done.
42+
43+
1. The package source code must be included in the bundle, as the generator is built to
44+
generate specs based from the Typescript AST. It is not set up to work with
45+
transpiled js code. You can do this by modifying your `package.json` to include your
46+
source code in the bundle. For example, if the source code is present in the `src/`
47+
directory, then add `src/` to the files array in the `package.json` of your project.
48+
2. After Step 1, change the `types` field in the `package.json` to be the entry point of
49+
the types in the source code. For example, if the entrypoint is `src/index.ts`, then
50+
set `"types": "src/index.ts"` in the `package.json`
51+
52+
## Defining Custom Codecs
53+
54+
When working with `openapi-generator`, you may encounter challenges with handling custom
55+
codecs that require JavaScript interpretation or aren't natively supported by the
56+
generator. These issues typically arise with codecs such as `new t.Type(...)` and other
57+
primitives that aren't directly supported. However, there are two solutions to address
58+
these challenges effectively. Click [here](#list-of-supported-io-ts-primitives) for the
59+
list of supported primitives.
60+
61+
### Solution 1: Defining Custom Codec Schemas in the Types Package (recommended)
62+
63+
`openapi-generator` now offers the ability to define the schema of custom codecs
64+
directly within the types package that defines them, rather than the downstream package
65+
that uses them. This approach is particularly useful for codecs that are used in many
66+
different types packages. Here’s how you can define schemas for your custom codecs in
67+
the upstream repository:
68+
69+
1. Create a file named `openapi-gen.config.js` in the root of your repository.
70+
71+
2. Add the following line to the `package.json` of the types package:
72+
73+
```json
74+
"customCodecFile": "openapi-gen.config.js"
75+
```
76+
77+
You must also add `"openapi-gen.config.js"` to the files field in the package.json,
78+
so that it is included in the final bundle.
79+
80+
3. In the `openapi-gen.config.js` file, define your custom codecs:
81+
82+
```javascript
83+
module.exports = (E) => {
84+
return {
85+
SampleCodecDefinition: () =>
86+
E.right({
87+
type: 'string',
88+
default: 'defaultString',
89+
minLength: 1,
90+
}),
91+
// ... rest of your custom codec definitions
92+
};
93+
};
94+
```
95+
96+
By following these steps, the schemas for your custom codecs will be included in the
97+
generated API docs for any endpoints that use the respective codecs. The input parameter
98+
`E` is the namespace import of `fp-ts/Either`, and the return type should be a `Record`
99+
containing AST definitions for external libraries. For more details, see
100+
[KNOWN_IMPORTS](./src/knownImports.ts).
101+
102+
### Solution 2: Using a Custom Codec Configuration File
103+
104+
`openapi-generator` supports importing codecs from other packages in `node_modules`, but
105+
it struggles with `io-ts` primitives that need JavaScript interpretation, such as
106+
`new t.Type(...)`. To work around this, you can define schemas for these codecs in a
107+
configuration file within your downstream types package (where you generate the API
108+
docs). This allows the generator to understand and use these schemas where necessary.
109+
Follow these steps to create and use a custom codec configuration file:
110+
111+
1. Create a JavaScript file with the following format:
112+
113+
```javascript
114+
module.exports = (E) => {
115+
return {
116+
'io-ts-bigint': {
117+
BigIntFromString: () => E.right({ type: 'string' }),
118+
NonZeroBigInt: () => E.right({ type: 'number' }),
119+
NonZeroBigIntFromString: () => E.right({ type: 'string' }),
120+
NegativeBigIntFromString: () => E.right({ type: 'string' }),
121+
NonNegativeBigIntFromString: () => E.right({ type: 'string' }),
122+
PositiveBigIntFromString: () => E.right({ type: 'string' }),
123+
},
124+
// ... and so on for other packages
125+
};
126+
};
127+
```
128+
129+
2. The input parameter `E` is the namespace import of `fp-ts/Either`, which avoids
130+
issues with `require`. The return type should be a `Record` containing AST
131+
definitions for external libraries. For more information on the structure, refer to
132+
[KNOWN_IMPORTS](./src/knownImports.ts).
133+
134+
## List of supported io-ts primitives
62135

63-
The input parameter `E` is the namespace import of `fp-ts/Either` (so that trying to
64-
`require` it from the config file isn't an issue), and the return type is a `Record`
65-
containing AST definitions for external libraries.
66-
[Refer to KNOWN_IMPORTS here for info on the structure](./src/knownImports.ts)
136+
- string
137+
- number
138+
- bigint
139+
- boolean
140+
- null
141+
- nullType
142+
- undefined
143+
- unknown
144+
- any
145+
- array
146+
- readonlyArray
147+
- object
148+
- type
149+
- partial
150+
- exact
151+
- strict
152+
- record
153+
- union
154+
- intersection
155+
- literal
156+
- keyof
157+
- brand
158+
- UnknownRecord
159+
- void

packages/openapi-generator/src/cli.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ const app = command({
125125
} else if (!isApiSpec(entryPoint, symbol.init.callee)) {
126126
continue;
127127
}
128-
logInfo(`[INFO] Found API spec in ${symbol.name}`);
128+
logInfo(`Found API spec in ${symbol.name}`);
129129

130130
const result = parseApiSpec(
131131
project.right,
@@ -171,17 +171,17 @@ const app = command({
171171

172172
const initE = findSymbolInitializer(project.right, sourceFile, ref.name);
173173
if (E.isLeft(initE)) {
174-
console.error(
175-
`[ERROR] Could not find symbol '${ref.name}' in '${ref.location}': ${initE.left}`,
174+
logError(
175+
`Could not find symbol '${ref.name}' in '${ref.location}': ${initE.left}`,
176176
);
177177
process.exit(1);
178178
}
179179
const [newSourceFile, init, comment] = initE.right;
180180

181181
const codecE = parseCodecInitializer(project.right, newSourceFile, init);
182182
if (E.isLeft(codecE)) {
183-
console.error(
184-
`[ERROR] Could not parse codec '${ref.name}' in '${ref.location}': ${codecE.left}`,
183+
logError(
184+
`Could not parse codec '${ref.name}' in '${ref.location}': ${codecE.left}`,
185185
);
186186
process.exit(1);
187187
}

packages/openapi-generator/src/project.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import resolve from 'resolve';
66

77
import { KNOWN_IMPORTS, type KnownCodec } from './knownImports';
88
import { parseSource, type SourceFile } from './sourceFile';
9-
import { errorLeft } from './error';
9+
import { errorLeft, logInfo } from './error';
1010

1111
const readFile = promisify(fs.readFile);
1212

@@ -37,6 +37,7 @@ export class Project {
3737
async parseEntryPoint(entryPoint: string): Promise<E.Either<string, Project>> {
3838
const queue: string[] = [entryPoint];
3939
let path: string | undefined;
40+
const visitedPackages = new Set<string>();
4041
while (((path = queue.pop()), path !== undefined)) {
4142
if (!['.ts', '.js'].includes(p.extname(path))) {
4243
continue;
@@ -59,6 +60,26 @@ export class Project {
5960
// If we are not resolving a relative path, we need to resolve the entry point
6061
const baseDir = p.dirname(sourceFile.path);
6162
let entryPoint = this.resolveEntryPoint(baseDir, sym.from);
63+
64+
if (!visitedPackages.has(sym.from)) {
65+
// This is a step that checks if this import has custom codecs, and loads them into known imports
66+
const codecs = await this.getCustomCodecs(baseDir, sym.from);
67+
if (E.isLeft(codecs)) {
68+
return codecs;
69+
}
70+
71+
if (Object.keys(codecs.right).length > 0) {
72+
this.knownImports[sym.from] = {
73+
...codecs.right,
74+
...this.knownImports[sym.from],
75+
};
76+
77+
logInfo(`Loaded custom codecs for ${sym.from}`);
78+
}
79+
}
80+
81+
visitedPackages.add(sym.from);
82+
6283
if (E.isLeft(entryPoint)) {
6384
continue;
6485
} else if (!this.has(entryPoint.right)) {
@@ -148,4 +169,45 @@ export class Project {
148169
getTypes() {
149170
return this.types;
150171
}
172+
173+
private async getCustomCodecs(
174+
basedir: string,
175+
packageName: string,
176+
): Promise<E.Either<string, Record<string, KnownCodec>>> {
177+
let packageJsonPath = '';
178+
179+
try {
180+
packageJsonPath = resolve.sync(`${packageName}/package.json`, {
181+
basedir,
182+
extensions: ['.json'],
183+
});
184+
} catch (e) {
185+
// This should not lead to the failure of the entire project, so return an empty record
186+
return E.right({});
187+
}
188+
189+
const packageInfo = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
190+
191+
if (packageInfo['customCodecFile']) {
192+
// The package defines their own custom codecs
193+
const customCodecPath = resolve.sync(
194+
`${packageName}/${packageInfo['customCodecFile']}`,
195+
{
196+
basedir,
197+
extensions: ['.ts', '.js'],
198+
},
199+
);
200+
201+
const module = await import(customCodecPath);
202+
if (module.default === undefined) {
203+
// Package does not have a default export so we can't use it. Format of the custom codec file is incorrect
204+
return errorLeft(`Could not find default export in ${customCodecPath}`);
205+
}
206+
207+
const customCodecs = module.default(E);
208+
return E.right(customCodecs);
209+
}
210+
211+
return E.right({});
212+
}
151213
}

packages/openapi-generator/src/sourceFile.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as swc from '@swc/core';
22

33
import { parseTopLevelSymbols, type SymbolTable } from './symbol';
4+
import { logWarn } from './error';
45

56
export type SourceFile = {
67
path: string;
@@ -41,7 +42,7 @@ export async function parseSource(
4142
span: module.span,
4243
};
4344
} catch (e: unknown) {
44-
console.error(`Error parsing source file: ${path}`, e);
45+
logWarn(`Error parsing source file: ${path}, ${e}`);
4546
return undefined;
4647
}
4748
}

packages/openapi-generator/test/externalModuleApiSpec.test.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,8 @@ async function testCase(
126126
components,
127127
);
128128

129-
assert.deepEqual(errors, expectedErrors);
130-
assert.deepEqual(openapi, expected);
129+
assert.deepStrictEqual(errors, expectedErrors);
130+
assert.deepStrictEqual(openapi, expected);
131131
});
132132
}
133133

@@ -319,3 +319,48 @@ testCase(
319319
},
320320
[]
321321
)
322+
323+
testCase("simple api spec with custom codec", "test/sample-types/apiSpecWithCustomCodec.ts", {
324+
openapi: "3.0.3",
325+
info: {
326+
title: "simple api spec with custom codec",
327+
version: "4.7.4",
328+
description: "simple api spec with custom codec"
329+
},
330+
paths: {
331+
"/test": {
332+
get: {
333+
parameters: [],
334+
responses: {
335+
200: {
336+
description: "OK",
337+
content: {
338+
'application/json': {
339+
schema: {
340+
type: 'string',
341+
description: 'Sample custom codec',
342+
example: 'sample',
343+
format: 'sample'
344+
}
345+
}
346+
}
347+
},
348+
201: {
349+
description: 'Created',
350+
content: {
351+
'application/json': {
352+
schema: {
353+
type: 'number',
354+
description: 'Another sample codec',
355+
}
356+
}
357+
}
358+
}
359+
}
360+
}
361+
}
362+
},
363+
components: {
364+
schemas: {}
365+
}
366+
}, []);
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { SampleCustomCodec, AnotherSampleCodec } from '@bitgo/custom-codecs';
2+
import * as h from '@api-ts/io-ts-http';
3+
4+
export const apiSpec = h.apiSpec({
5+
'api.get.test': {
6+
get: h.httpRoute({
7+
path: '/test',
8+
method: 'GET',
9+
request: h.httpRequest({}),
10+
response: {
11+
200: SampleCustomCodec,
12+
201: AnotherSampleCodec,
13+
},
14+
}),
15+
},
16+
})

0 commit comments

Comments
 (0)