@specbook/core 0.1.0 → 0.2.0
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/package.json +1 -1
- package/src/config.ts +2 -0
- package/src/index.ts +10 -1
- package/src/oauth.ts +164 -0
- package/src/runner/run.ts +3 -0
- package/src/spec-config.ts +144 -13
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface EnvironmentConfig {
|
|
|
11
11
|
* "prod", amber otherwise (`mock` is always neutral).
|
|
12
12
|
*/
|
|
13
13
|
risk?: 'production' | 'sandbox'
|
|
14
|
+
/** One line on what this environment is; shown as the tooltip of its entry in the pickers. */
|
|
15
|
+
description?: string
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export interface ServiceConfig<E extends string = string> {
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export { fromFormRows, fromRows, toFormRows, toRows } from './example'
|
|
|
6
6
|
export type { FakerConfig } from './faker'
|
|
7
7
|
export { faker, fakerLocaleNames, installFaker, prepareFaker, setFaker } from './faker'
|
|
8
8
|
export * from './mock'
|
|
9
|
+
export * from './oauth'
|
|
9
10
|
export * from './proxy/index'
|
|
10
11
|
export * from './reporters/index'
|
|
11
12
|
export { defineOperation, request } from './request'
|
|
@@ -15,7 +16,15 @@ export type { Sequence, SequenceMeta, StepCtx, StepNode } from './sequence'
|
|
|
15
16
|
export { sequence } from './sequence'
|
|
16
17
|
export type { SnippetInput, SnippetLang } from './snippets'
|
|
17
18
|
export { snippetLangs, toSnippet } from './snippets'
|
|
18
|
-
export {
|
|
19
|
+
export {
|
|
20
|
+
authFromSpec,
|
|
21
|
+
type JsonAuthProfile,
|
|
22
|
+
liveBaseUrl,
|
|
23
|
+
MAX_SERVERS,
|
|
24
|
+
openApiServers,
|
|
25
|
+
type ServerEntry,
|
|
26
|
+
type SpecAuth,
|
|
27
|
+
} from './spec-config'
|
|
19
28
|
export {
|
|
20
29
|
collectPlaceholders,
|
|
21
30
|
deepMerge,
|
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.0 authorization code with PKCE (RFC 7636), as pure helpers over Web Crypto so the browser and
|
|
3
|
+
* Node run the same code. Nothing here stores anything: a verifier is held by the caller, in memory.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class OAuthError extends Error {
|
|
7
|
+
constructor(message: string) {
|
|
8
|
+
super(message)
|
|
9
|
+
this.name = 'OAuthError'
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The endpoints are written out: an OAuth 2.0 authorization code flow. */
|
|
14
|
+
export interface OAuthEndpoints {
|
|
15
|
+
authorizationUrl: string
|
|
16
|
+
tokenUrl: string
|
|
17
|
+
/** scope name -> description */
|
|
18
|
+
scopes?: Record<string, string>
|
|
19
|
+
/** a public identifier; pre-fills the sign-in prompt */
|
|
20
|
+
clientId?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The endpoints are found in an OpenID Connect discovery document. */
|
|
24
|
+
export interface OAuthDiscovery {
|
|
25
|
+
openIdConnectUrl: string
|
|
26
|
+
scopes?: Record<string, string>
|
|
27
|
+
clientId?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What a spec or a config profile declares for an interactive sign-in. */
|
|
31
|
+
export type OAuthSettings = OAuthEndpoints | OAuthDiscovery
|
|
32
|
+
|
|
33
|
+
export const isDiscovery = (o: OAuthSettings): o is OAuthDiscovery => 'openIdConnectUrl' in o
|
|
34
|
+
|
|
35
|
+
/** Endpoints read from a discovery document. */
|
|
36
|
+
export interface DiscoveredEndpoints {
|
|
37
|
+
authorizationUrl: string
|
|
38
|
+
tokenUrl: string
|
|
39
|
+
/** scope name -> '' (a discovery document lists names only) */
|
|
40
|
+
scopes: Record<string, string>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The two endpoints a sign-in needs, from `/.well-known/openid-configuration`. Only the access token is
|
|
45
|
+
* used, so nothing else in the document (ID-token signing keys, `nonce` support) matters here.
|
|
46
|
+
*/
|
|
47
|
+
export function parseDiscovery(json: unknown): DiscoveredEndpoints {
|
|
48
|
+
const d = (json && typeof json === 'object' ? json : {}) as Record<string, unknown>
|
|
49
|
+
const url = (v: unknown) => (typeof v === 'string' && /^https?:\/\//i.test(v) ? v : undefined)
|
|
50
|
+
const authorizationUrl = url(d.authorization_endpoint)
|
|
51
|
+
const tokenUrl = url(d.token_endpoint)
|
|
52
|
+
if (!authorizationUrl || !tokenUrl)
|
|
53
|
+
throw new OAuthError('The provider’s discovery document has no authorization or token endpoint')
|
|
54
|
+
const supported = Array.isArray(d.scopes_supported)
|
|
55
|
+
? d.scopes_supported.filter((x): x is string => typeof x === 'string')
|
|
56
|
+
: []
|
|
57
|
+
// `openid` is what makes it an OpenID Connect request; list it first even when the document omits it
|
|
58
|
+
const names = ['openid', ...supported.filter((x) => x !== 'openid')]
|
|
59
|
+
return { authorizationUrl, tokenUrl, scopes: Object.fromEntries(names.map((n) => [n, ''])) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const base64url = (bytes: Uint8Array): string => {
|
|
63
|
+
let bin = ''
|
|
64
|
+
for (const b of bytes) bin += String.fromCharCode(b)
|
|
65
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const random = (bytes: number) => base64url(crypto.getRandomValues(new Uint8Array(bytes)))
|
|
69
|
+
|
|
70
|
+
/** `code_challenge` for a verifier: BASE64URL(SHA-256(verifier)). */
|
|
71
|
+
export async function challengeFor(verifier: string): Promise<string> {
|
|
72
|
+
// WebCrypto's `subtle` exists only on a secure page (https, or localhost); a viewer served over plain
|
|
73
|
+
// http, or framed inside such a page, has none. Say so, rather than fail on `undefined.digest`.
|
|
74
|
+
if (!globalThis.crypto?.subtle)
|
|
75
|
+
throw new OAuthError(
|
|
76
|
+
'Sign-in needs a secure page (https, or localhost). Open the viewer over https, or paste an access token.',
|
|
77
|
+
)
|
|
78
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
|
|
79
|
+
return base64url(new Uint8Array(digest))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A fresh verifier (64 characters from the unreserved set) and its S256 challenge. */
|
|
83
|
+
export async function createPkce(): Promise<{ verifier: string; challenge: string }> {
|
|
84
|
+
const verifier = random(48)
|
|
85
|
+
return { verifier, challenge: await challengeFor(verifier) }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const newState = (): string => random(24)
|
|
89
|
+
|
|
90
|
+
export function authorizeUrl(o: {
|
|
91
|
+
authorizationUrl: string
|
|
92
|
+
clientId: string
|
|
93
|
+
redirectUri: string
|
|
94
|
+
scopes?: string[]
|
|
95
|
+
state: string
|
|
96
|
+
challenge: string
|
|
97
|
+
}): string {
|
|
98
|
+
const url = new URL(o.authorizationUrl)
|
|
99
|
+
url.searchParams.set('response_type', 'code')
|
|
100
|
+
url.searchParams.set('client_id', o.clientId)
|
|
101
|
+
url.searchParams.set('redirect_uri', o.redirectUri)
|
|
102
|
+
if (o.scopes?.length) url.searchParams.set('scope', o.scopes.join(' '))
|
|
103
|
+
url.searchParams.set('state', o.state)
|
|
104
|
+
url.searchParams.set('code_challenge', o.challenge)
|
|
105
|
+
url.searchParams.set('code_challenge_method', 'S256')
|
|
106
|
+
return url.toString()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The code from the redirect, once `state` has been checked; throws what the server or the check said. */
|
|
110
|
+
export function parseCallback(
|
|
111
|
+
params: URLSearchParams | Record<string, string | null | undefined>,
|
|
112
|
+
expectedState: string,
|
|
113
|
+
): { code: string } {
|
|
114
|
+
const get = (k: string) =>
|
|
115
|
+
(params instanceof URLSearchParams ? params.get(k) : params[k]) ?? undefined
|
|
116
|
+
const error = get('error')
|
|
117
|
+
if (error) {
|
|
118
|
+
const detail = get('error_description')
|
|
119
|
+
throw new OAuthError(`Sign-in was refused: ${detail ? `${error} (${detail})` : error}`)
|
|
120
|
+
}
|
|
121
|
+
if (get('state') !== expectedState)
|
|
122
|
+
throw new OAuthError('Sign-in was rejected: the response did not match this request (state)')
|
|
123
|
+
const code = get('code')
|
|
124
|
+
if (!code) throw new OAuthError('Sign-in returned no authorization code')
|
|
125
|
+
return { code }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The form fields of the code exchange. A public client sends the verifier and no client secret. */
|
|
129
|
+
export function tokenRequestFields(o: {
|
|
130
|
+
code: string
|
|
131
|
+
verifier: string
|
|
132
|
+
clientId: string
|
|
133
|
+
redirectUri: string
|
|
134
|
+
}): Record<string, string> {
|
|
135
|
+
return {
|
|
136
|
+
grant_type: 'authorization_code',
|
|
137
|
+
code: o.code,
|
|
138
|
+
redirect_uri: o.redirectUri,
|
|
139
|
+
client_id: o.clientId,
|
|
140
|
+
code_verifier: o.verifier,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const tokenRequestBody = (o: Parameters<typeof tokenRequestFields>[0]): string =>
|
|
145
|
+
new URLSearchParams(tokenRequestFields(o)).toString()
|
|
146
|
+
|
|
147
|
+
export function parseTokenResponse(json: unknown): {
|
|
148
|
+
accessToken: string
|
|
149
|
+
expiresIn?: number
|
|
150
|
+
scope?: string
|
|
151
|
+
} {
|
|
152
|
+
const r = (json && typeof json === 'object' ? json : {}) as Record<string, unknown>
|
|
153
|
+
if (typeof r.error === 'string') {
|
|
154
|
+
const detail = typeof r.error_description === 'string' ? ` (${r.error_description})` : ''
|
|
155
|
+
throw new OAuthError(`The token endpoint refused the code: ${r.error}${detail}`)
|
|
156
|
+
}
|
|
157
|
+
if (typeof r.access_token !== 'string' || r.access_token === '')
|
|
158
|
+
throw new OAuthError('The token endpoint returned no access token')
|
|
159
|
+
return {
|
|
160
|
+
accessToken: r.access_token,
|
|
161
|
+
...(typeof r.expires_in === 'number' ? { expiresIn: r.expires_in } : {}),
|
|
162
|
+
...(typeof r.scope === 'string' ? { scope: r.scope } : {}),
|
|
163
|
+
}
|
|
164
|
+
}
|
package/src/runner/run.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createAsserter } from '../assert'
|
|
2
2
|
import { ControlError, type Controls, describeControls, resolveControls } from '../controls'
|
|
3
3
|
import type { Example, ExampleLoader, FileReader } from '../example'
|
|
4
|
+
import type { OAuthSettings } from '../oauth'
|
|
4
5
|
import { MissingSecretsError, maskDeep } from '../secrets'
|
|
5
6
|
import type { Sequence, StepCtx } from '../sequence'
|
|
6
7
|
import {
|
|
@@ -40,6 +41,8 @@ export interface AuthProfile {
|
|
|
40
41
|
token?: (res: ApiResponse<number, any>) => Session
|
|
41
42
|
apply: { headers?: Record<string, string>; query?: Record<string, string> }
|
|
42
43
|
retryOn401?: boolean
|
|
44
|
+
/** an interactive sign-in the UI can offer (authorization code with PKCE); the runner ignores it */
|
|
45
|
+
oauth?: OAuthSettings
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
export type RunEvent =
|
package/src/spec-config.ts
CHANGED
|
@@ -3,11 +3,43 @@
|
|
|
3
3
|
* the CLI's `preview` and the browser viewer, so both read a spec the same way.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { OAuthSettings } from './oauth'
|
|
7
|
+
|
|
6
8
|
const isUrl = (s: string) => /^https?:\/\//i.test(s)
|
|
7
9
|
|
|
8
10
|
export interface JsonAuthProfile {
|
|
9
11
|
title: string
|
|
10
12
|
apply: { headers?: Record<string, string>; query?: Record<string, string> }
|
|
13
|
+
/** present when the scheme has an authorization code flow the UI can sign in to */
|
|
14
|
+
oauth?: OAuthSettings
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** An OpenID Connect scheme: the discovery document says where to sign in. */
|
|
18
|
+
function discovery(s: any, base: string | undefined): OAuthSettings | undefined {
|
|
19
|
+
if (typeof s.openIdConnectUrl !== 'string' || s.openIdConnectUrl === '') return undefined
|
|
20
|
+
try {
|
|
21
|
+
return { openIdConnectUrl: new URL(s.openIdConnectUrl, base).toString() }
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The authorization code flow of an OAuth scheme (OpenAPI 3 `flows`, or Swagger 2 `accessCode`). */
|
|
28
|
+
function codeFlow(s: any, base: string | undefined): OAuthSettings | undefined {
|
|
29
|
+
const flow = s.flows?.authorizationCode ?? (s.flow === 'accessCode' ? s : undefined)
|
|
30
|
+
if (!flow?.authorizationUrl || !flow?.tokenUrl) return undefined
|
|
31
|
+
const abs = (u: string) => {
|
|
32
|
+
try {
|
|
33
|
+
return new URL(u, base).toString()
|
|
34
|
+
} catch {
|
|
35
|
+
return u
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
authorizationUrl: abs(flow.authorizationUrl),
|
|
40
|
+
tokenUrl: abs(flow.tokenUrl),
|
|
41
|
+
...(flow.scopes && Object.keys(flow.scopes).length ? { scopes: flow.scopes } : {}),
|
|
42
|
+
}
|
|
11
43
|
}
|
|
12
44
|
|
|
13
45
|
export interface SpecAuth {
|
|
@@ -28,7 +60,7 @@ const secretName = (scheme: string) =>
|
|
|
28
60
|
* Turns a document's security schemes into auth profiles whose secrets are filled in at run time, so
|
|
29
61
|
* previewing a protected API only needs the user to paste a key. OAuth-style schemes take an access token.
|
|
30
62
|
*/
|
|
31
|
-
export function authFromSpec(api: any): SpecAuth {
|
|
63
|
+
export function authFromSpec(api: any, source?: string): SpecAuth {
|
|
32
64
|
const schemes: Record<string, any> =
|
|
33
65
|
api.components?.securitySchemes ?? api.securityDefinitions ?? {}
|
|
34
66
|
const out: SpecAuth = { auth: {}, secrets: {} }
|
|
@@ -37,6 +69,7 @@ export function authFromSpec(api: any): SpecAuth {
|
|
|
37
69
|
const ref = `{{secrets.${secret}}}`
|
|
38
70
|
let apply: JsonAuthProfile['apply'] | undefined
|
|
39
71
|
let title = name
|
|
72
|
+
let oauth: OAuthSettings | undefined
|
|
40
73
|
if (s.type === 'apiKey') {
|
|
41
74
|
const key = s.name as string
|
|
42
75
|
if (s.in === 'header') apply = { headers: { [key]: ref } }
|
|
@@ -55,10 +88,12 @@ export function authFromSpec(api: any): SpecAuth {
|
|
|
55
88
|
title = `${name} (basic auth, paste base64 of user:password)`
|
|
56
89
|
} else if (s.type === 'oauth2' || s.type === 'openIdConnect') {
|
|
57
90
|
apply = { headers: { authorization: `Bearer ${ref}` } }
|
|
58
|
-
|
|
91
|
+
const base = source && isUrl(source) ? source : undefined
|
|
92
|
+
oauth = s.type === 'oauth2' ? codeFlow(s, base) : discovery(s, base)
|
|
93
|
+
title = `${name} (OAuth, ${oauth ? 'sign in or ' : ''}paste an access token)`
|
|
59
94
|
}
|
|
60
95
|
if (!apply) continue
|
|
61
|
-
out.auth[name] = { title, apply }
|
|
96
|
+
out.auth[name] = { title, apply, ...(oauth ? { oauth } : {}) }
|
|
62
97
|
out.secrets[secret] = {}
|
|
63
98
|
}
|
|
64
99
|
// the document's top-level `security` says which scheme most operations use
|
|
@@ -69,16 +104,112 @@ export function authFromSpec(api: any): SpecAuth {
|
|
|
69
104
|
return out
|
|
70
105
|
}
|
|
71
106
|
|
|
72
|
-
/**
|
|
73
|
-
export
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
107
|
+
/** One server a spec lists, as an environment the UI can switch to. */
|
|
108
|
+
export interface ServerEntry {
|
|
109
|
+
/** the environment name: `live` for the first, else a slug of its description or host */
|
|
110
|
+
name: string
|
|
111
|
+
url: string
|
|
112
|
+
description?: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `live` plus this many minus one: a spec with a server per region should not bury the picker. */
|
|
116
|
+
export const MAX_SERVERS = 20
|
|
117
|
+
|
|
118
|
+
const slug = (s: string) =>
|
|
119
|
+
s
|
|
120
|
+
.toLowerCase()
|
|
121
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
122
|
+
.replace(/^-+|-+$/g, '')
|
|
123
|
+
.slice(0, 24)
|
|
124
|
+
.replace(/-+$/, '')
|
|
125
|
+
|
|
126
|
+
/** An absolute address for one `servers` entry: variables at their defaults, a relative URL against `source`. */
|
|
127
|
+
function resolveServer(entry: any, source: string | undefined): string | undefined {
|
|
128
|
+
if (typeof entry?.url !== 'string') return undefined
|
|
129
|
+
const url = entry.url
|
|
130
|
+
.replace(/\{([^}]+)\}/g, (whole: string, key: string) => {
|
|
131
|
+
const d = entry.variables?.[key]?.default
|
|
132
|
+
return d === undefined || d === null ? whole : String(d)
|
|
133
|
+
})
|
|
134
|
+
.trim()
|
|
135
|
+
if (/\{[^}]*\}/.test(url)) return undefined
|
|
136
|
+
if (isUrl(url)) return url.replace(/\/+$/, '')
|
|
137
|
+
if (source) {
|
|
138
|
+
try {
|
|
139
|
+
return new URL(url, source).toString().replace(/\/+$/, '')
|
|
140
|
+
} catch {
|
|
141
|
+
/* not an address */
|
|
142
|
+
}
|
|
79
143
|
}
|
|
80
|
-
if (api.host)
|
|
81
|
-
return `${api.schemes?.[0] ?? 'https'}://${api.host}${api.basePath ?? ''}`.replace(/\/$/, '')
|
|
82
|
-
if (isUrl(source)) return new URL(source).origin
|
|
83
144
|
return undefined
|
|
84
145
|
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The servers a document lists, as environments. The first is `live` (what a single-server spec has always
|
|
149
|
+
* had); the rest are named from their description, else their host, and are always a valid environment name.
|
|
150
|
+
*
|
|
151
|
+
* `needsBaseUrl` is true when the first server is relative and `source` cannot resolve it: the person is
|
|
152
|
+
* asked for the address, and what they type is `live`. `omitted` counts usable servers beyond `MAX_SERVERS`.
|
|
153
|
+
*/
|
|
154
|
+
export function openApiServers(
|
|
155
|
+
api: any,
|
|
156
|
+
source: string,
|
|
157
|
+
): { servers: ServerEntry[]; needsBaseUrl: boolean; omitted: number } {
|
|
158
|
+
const src = isUrl(source) ? source : undefined
|
|
159
|
+
const listed: any[] = Array.isArray(api.servers) ? api.servers : []
|
|
160
|
+
const servers: ServerEntry[] = []
|
|
161
|
+
const describe = (e: any) =>
|
|
162
|
+
typeof e?.description === 'string' && e.description.trim() !== ''
|
|
163
|
+
? { description: e.description.trim() as string }
|
|
164
|
+
: {}
|
|
165
|
+
|
|
166
|
+
if (listed.length === 0) {
|
|
167
|
+
// Swagger 2, or an OpenAPI 3 document that says nothing: the host, else wherever it was fetched from
|
|
168
|
+
if (api.host)
|
|
169
|
+
servers.push({
|
|
170
|
+
name: 'live',
|
|
171
|
+
url: `${api.schemes?.[0] ?? 'https'}://${api.host}${api.basePath ?? ''}`.replace(
|
|
172
|
+
/\/+$/,
|
|
173
|
+
'',
|
|
174
|
+
),
|
|
175
|
+
})
|
|
176
|
+
else if (src) servers.push({ name: 'live', url: new URL(src).origin })
|
|
177
|
+
return { servers, needsBaseUrl: false, omitted: 0 }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const first = resolveServer(listed[0], src)
|
|
181
|
+
if (first) servers.push({ name: 'live', url: first, ...describe(listed[0]) })
|
|
182
|
+
const needsBaseUrl =
|
|
183
|
+
!first && typeof listed[0]?.url === 'string' && !/^https?:\/\//i.test(listed[0].url)
|
|
184
|
+
|
|
185
|
+
const taken = new Set(['mock', 'live'])
|
|
186
|
+
const seen = new Set(servers.map((x) => x.url))
|
|
187
|
+
let omitted = 0
|
|
188
|
+
for (const entry of listed.slice(1)) {
|
|
189
|
+
const url = resolveServer(entry, src)
|
|
190
|
+
if (!url || seen.has(url)) continue
|
|
191
|
+
if (servers.length >= MAX_SERVERS) {
|
|
192
|
+
omitted++
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
seen.add(url)
|
|
196
|
+
const host = (() => {
|
|
197
|
+
try {
|
|
198
|
+
return new URL(url).host
|
|
199
|
+
} catch {
|
|
200
|
+
return ''
|
|
201
|
+
}
|
|
202
|
+
})()
|
|
203
|
+
const base = slug(describe(entry).description ?? '') || slug(host) || 'server'
|
|
204
|
+
let name = base
|
|
205
|
+
for (let n = 2; taken.has(name); n++) name = `${base}-${n}`
|
|
206
|
+
taken.add(name)
|
|
207
|
+
servers.push({ name, url, ...describe(entry) })
|
|
208
|
+
}
|
|
209
|
+
return { servers, needsBaseUrl, omitted }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Where the API lives, from `servers[0]` (OpenAPI 3) or `host`/`basePath` (Swagger 2). */
|
|
213
|
+
export function liveBaseUrl(api: any, source: string): string | undefined {
|
|
214
|
+
return openApiServers(api, source).servers.find((s) => s.name === 'live')?.url
|
|
215
|
+
}
|