Skip to content

Commit 5beee35

Browse files
authored
feat: support remote specs with authentication (#230)
- new cli param `--remote-spec-request-headers` / `OPENAPI_REMOTE_SPEC_REQUEST_HEADERS` - accepts a JSON object like `{[uri: string]: {name: string, value: string} | {name: string, value: string}[]}` - I don't _love_ using JSON as a format for this, however at least it's standardized and escaping values etc is well understood. By using JSON I'm confident that there won't be any edge cases with spaces or other separaters that would need handling with a format like `<uri>:<name>:<value>` - main use-case is for generating from remote specs that are behind some format of request header based authentication (eg: private github repos, GCP IAP proxy, etc) - Previously I was having to `curl` these to the local filesystem first, which is inconvenient closes #229
1 parent 2a37e35 commit 5beee35

9 files changed

Lines changed: 316 additions & 32 deletions

File tree

.husky/pre-commit

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1 @@
1-
#!/usr/bin/env sh
2-
. "$(dirname -- "$0")/_/husky.sh"
3-
41
yarn lint-staged

packages/documentation/src/pages/reference/cli-options.mdx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,72 @@ Default: `false` (use `unknown` rather than `any`)
115115

116116
### Misc
117117

118+
#### `--remote-spec-request-headers` (authenticated remote specifications)
119+
As environment variable `OPENAPI_REMOTE_SPEC_REQUEST_HEADERS`
120+
121+
Allows providing request headers to use when fetching remote specifications. This allows for running
122+
generation against remote sources that require authentication.
123+
124+
Common examples include [private github repositories](https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api),
125+
urls secured by an authenticating proxy like [GCP IAP Proxy](https://cloud.google.com/iap/docs/concepts-overview),
126+
or just generally authenticated servers
127+
128+
129+
<Callout type="warning" emoji="⚠️">
130+
We strongly recommend using the **environment variable** variant of this option
131+
(`OPENAPI_REMOTE_SPEC_REQUEST_HEADERS`), as values for this option will likely include secrets, and it
132+
is best to keep these out of your shell history.
133+
</Callout>
134+
135+
The format of the value is a JSON object keyed by URI, with values being either an object,
136+
or array of `{name, value}` pairs. As a typescript type:
137+
```typescript
138+
type value = {
139+
[uri: string]: { name: string, value: string }[] | { name: string, value: string }
140+
}
141+
```
142+
143+
For example:
144+
```json
145+
{
146+
"https://example.com": [
147+
{"name": "X-Client-Id", "value": "my-client-id"},
148+
{"name": "X-Client-Secret", "value": "some-secret-value"}
149+
],
150+
"https://example.org/some/path": {"name": "Proxy-Authorization", "value": "some token"}
151+
}
152+
```
153+
154+
A full match on the provided uri is required for the headers to be sent.
155+
Eg: given a uri of "https://exmaple.com:8080/openapi.yaml" the headers would **not**
156+
be sent for requests to other ports, resource paths, or protocols, but a less specific
157+
uri like "https://example.com" will send headers on any (`https`) request to that domain.
158+
159+
<Callout emoji="💡">
160+
Why JSON you ask? Simply put it has well defined semantics, and is easy to parse without fear of jumbling the pieces together.
161+
162+
Unfortunately it is a little annoying to formulate in shell scripts, so here's some examples to get you started
163+
164+
Using [jq](https://jqlang.github.io/jq/):
165+
```shell
166+
jq --null-input --compact-output \
167+
--arg domain "https://example.com" \
168+
--arg name "authorization" \
169+
--arg value "secret value" '{$domain: {$name, $value}}'
170+
```
171+
172+
Using [nodejs](https://nodejs.org/):
173+
```shell
174+
node -p 'JSON.stringify({[process.argv[1]]: {name: process.argv[2], value: process.argv[3]}})' \
175+
https://example.com \
176+
authorization \
177+
'some secret value'
178+
```
179+
180+
Where typically in either example the values would be coming from shell variables, eg: storing a short-lived
181+
access token, etc.
182+
</Callout>
183+
118184
#### `-h, --help`
119185
Displays help text for command
120186

packages/openapi-code-generator/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@
5151
"json5": "^2.2.3",
5252
"lodash": "^4.17.21",
5353
"source-map-support": "^0.5.21",
54-
"tslib": "^2.6.3"
54+
"tslib": "^2.6.3",
55+
"zod": "^3.23.8"
5556
},
5657
"peerDependencies": {
5758
"@typespec/compiler": "^0.58.0",
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {describe, it} from "@jest/globals"
2+
import {boolParser, remoteSpecRequestHeadersParser} from "./cli"
3+
4+
describe("cli", () => {
5+
describe("boolParser", () => {
6+
it.each([
7+
["true", true],
8+
["1", true],
9+
["on", true],
10+
["TRUE", true],
11+
["1", true],
12+
["ON", true],
13+
["false", false],
14+
["0", false],
15+
["off", false],
16+
["FALSE", false],
17+
["0", false],
18+
["OFF", false],
19+
])("%s -> %s", (input, expected) => {
20+
expect(boolParser(input)).toBe(expected)
21+
})
22+
})
23+
24+
describe("remoteSpecRequestHeadersParser", () => {
25+
it("accepts a single header", () => {
26+
expect(
27+
remoteSpecRequestHeadersParser(
28+
JSON.stringify({
29+
"https://example.com/": {
30+
name: "some-header-name",
31+
value: "some-header-value",
32+
},
33+
}),
34+
),
35+
).toEqual({
36+
"https://example.com/": [
37+
{name: "some-header-name", value: "some-header-value"},
38+
],
39+
})
40+
})
41+
42+
it("accepts multiple headers", () => {
43+
expect(
44+
remoteSpecRequestHeadersParser(
45+
JSON.stringify({
46+
"https://example.com/": [
47+
{
48+
name: "some-header-name",
49+
value: "some-header-value",
50+
},
51+
{
52+
name: "some-other-header-name",
53+
value: "some-other-header-value",
54+
},
55+
],
56+
}),
57+
),
58+
).toEqual({
59+
"https://example.com/": [
60+
{name: "some-header-name", value: "some-header-value"},
61+
{name: "some-other-header-name", value: "some-other-header-value"},
62+
],
63+
})
64+
})
65+
})
66+
})

packages/openapi-code-generator/src/cli.ts

Lines changed: 71 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
InvalidArgumentError,
99
Option,
1010
} from "@commander-js/extra-typings"
11+
import {z} from "zod"
1112
import {promptContinue} from "./core/cli-utils"
1213
import {NodeFsAdaptor} from "./core/file-system/node-fs-adaptor"
1314
import type {OperationGroupStrategy} from "./core/input"
@@ -19,7 +20,7 @@ import {generate} from "./index"
1920
import type {templates} from "./templates"
2021
import {TypescriptFormatterBiome} from "./typescript/common/typescript-formatter.biome"
2122

22-
const boolParser = (arg: string): boolean => {
23+
export const boolParser = (arg: string): boolean => {
2324
const TRUTHY_VALUES = ["true", "1", "on"]
2425
const FALSY_VALUES = ["false", "0", "off", ""]
2526

@@ -39,17 +40,38 @@ const boolParser = (arg: string): boolean => {
3940
)
4041
}
4142

43+
export const remoteSpecRequestHeadersParser = (arg: string) => {
44+
return z
45+
.preprocess(
46+
(str) =>
47+
z
48+
.string()
49+
.transform((it) => JSON.parse(it))
50+
.parse(str),
51+
z.record(
52+
z.string(),
53+
z.preprocess(
54+
(it) => (!it || Array.isArray(it) ? it : [it]),
55+
z.array(
56+
z.object({
57+
name: z.string(),
58+
value: z.string(),
59+
}),
60+
),
61+
),
62+
),
63+
)
64+
.parse(arg)
65+
}
66+
4267
const program = new Command()
4368
.addOption(
44-
new Option("-i --input <value>", "input file to generate from")
69+
new Option("-i --input <value>", "input specification to generate from")
4570
.env("OPENAPI_INPUT")
4671
.makeOptionMandatory(),
4772
)
4873
.addOption(
49-
new Option(
50-
"--input-type <value>",
51-
"type of input file. this can be openapi3 or typespec",
52-
)
74+
new Option("--input-type <value>", "type of input specification")
5375
.env("OPENAPI_INPUT_TYPE")
5476
.choices(["openapi3", "typespec"] as const)
5577
.default("openapi3" as const)
@@ -74,7 +96,7 @@ const program = new Command()
7496
.addOption(
7597
new Option(
7698
"-s --schema-builder <value>",
77-
"runtime schema parsing library to use",
99+
"(typescript) runtime schema parsing library to use",
78100
)
79101
.env("OPENAPI_SCHEMA_BUILDER")
80102
.choices(["zod", "joi"] as const)
@@ -84,7 +106,7 @@ const program = new Command()
84106
.addOption(
85107
new Option(
86108
"--ts-allow-any [bool]",
87-
"(typescript) whether to use `any` or `unknown` for unspecified types",
109+
`(typescript) whether to use "any" or "unknown" for unspecified types`,
88110
)
89111
.env("OPENAPI_TS_ALLOW_ANY")
90112
.argParser(boolParser)
@@ -93,7 +115,7 @@ const program = new Command()
93115
.addOption(
94116
new Option(
95117
"--enable-runtime-response-validation [bool]",
96-
"(experimental) whether to validate response bodies using the chosen runtime schema library",
118+
"(experimental) (client sdks only) whether to validate response bodies using the chosen runtime schema library",
97119
)
98120
.env("OPENAPI_ENABLE_RUNTIME_RESPONSE_VALIDATION")
99121
.argParser(boolParser)
@@ -111,7 +133,7 @@ const program = new Command()
111133
.addOption(
112134
new Option(
113135
"--allow-unused-imports [bool]",
114-
"Keep unused imports. Especially useful if there is a bug in the unused-import elimination.",
136+
"Keep unused imports. Primarily useful if a bug occurs in the unused-import elimination.",
115137
)
116138
.env("OPENAPI_ALLOW_UNUSED_IMPORTS")
117139
.argParser(boolParser)
@@ -120,7 +142,10 @@ const program = new Command()
120142
.addOption(
121143
new Option(
122144
"--grouping-strategy <value>",
123-
"(experimental) Strategy to use for splitting output into separate files. Set to none for a single generated.ts",
145+
`
146+
(experimental) Strategy to use for splitting output into separate files.
147+
148+
Set to none for a single generated.ts`,
124149
)
125150
.env("OPENAPI_GROUPING_STRATEGY")
126151
.choices([
@@ -131,12 +156,32 @@ const program = new Command()
131156
.default("none" as const)
132157
.makeOptionMandatory(),
133158
)
134-
.showHelpAfterError()
135-
.parse()
159+
.addOption(
160+
new Option(
161+
"--remote-spec-request-headers <value>",
162+
`
163+
Request headers to use when fetching remote specifications.
164+
165+
Format is a JSON object keyed by domain name/uri, with values {name, value}[].
166+
Eg: '{"https://example.com": [{"name": "Authorization", "value": "some arbitrary value"}]}'.
136167
137-
const config = program.opts()
168+
Use this if you're generating from a uri that requires authentication.
169+
170+
A full match on the provided domain/uri is required for the headers to be sent.
171+
Eg: given a uri of "https://exmaple.com:8080/openapi.yaml" the headers would not
172+
be sent for requests to other ports, resource paths, or protocols, but a less specific
173+
uri like "https://example.com" will send headers on any request to that domain.
174+
175+
Using the environment variable variant is recommended to keep secrets out of your shell history`,
176+
)
177+
.env("OPENAPI_REMOTE_SPEC_REQUEST_HEADERS")
178+
.argParser(remoteSpecRequestHeadersParser),
179+
)
180+
.showHelpAfterError()
138181

139182
async function main() {
183+
const config = program.parse().opts()
184+
140185
const fsAdaptor = new NodeFsAdaptor()
141186
// TODO: make switchable with prettier / auto-detect from project?
142187
const formatter = await TypescriptFormatterBiome.createNodeFormatter()
@@ -173,15 +218,17 @@ async function main() {
173218
)
174219
}
175220

176-
main()
177-
.then(() => {
178-
logger.info("generation complete!")
179-
logger.info("elapsed", logger.toJSON())
221+
if (require.main === module) {
222+
main()
223+
.then(() => {
224+
logger.info("generation complete!")
225+
logger.info("elapsed", logger.toJSON())
180226

181-
process.exit(0)
182-
})
183-
.catch((err) => {
184-
logger.error("unhandled error", err)
227+
process.exit(0)
228+
})
229+
.catch((err) => {
230+
logger.error("unhandled error", err)
185231

186-
process.exit(1)
187-
})
232+
process.exit(1)
233+
})
234+
}

0 commit comments

Comments
 (0)