@specbook/core 0.1.0 → 0.3.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.
@@ -3,11 +3,46 @@
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
+ /** Gives the address a profile field should carry for a raw URL from the document. */
18
+ type Address = (field: 'authorizationUrl' | 'tokenUrl' | 'openIdConnectUrl', raw: string) => string
19
+
20
+ /** An OpenID Connect scheme: the discovery document says where to sign in. */
21
+ function discovery(s: any, address: Address): OAuthSettings | undefined {
22
+ if (typeof s.openIdConnectUrl !== 'string' || s.openIdConnectUrl === '') return undefined
23
+ try {
24
+ return { openIdConnectUrl: address('openIdConnectUrl', s.openIdConnectUrl) }
25
+ } catch {
26
+ return undefined
27
+ }
28
+ }
29
+
30
+ /** The authorization code flow of an OAuth scheme (OpenAPI 3 `flows`, or Swagger 2 `accessCode`). */
31
+ function codeFlow(s: any, address: Address): OAuthSettings | undefined {
32
+ const flow = s.flows?.authorizationCode ?? (s.flow === 'accessCode' ? s : undefined)
33
+ if (!flow?.authorizationUrl || !flow?.tokenUrl) return undefined
34
+ const abs = (field: 'authorizationUrl' | 'tokenUrl', u: string) => {
35
+ try {
36
+ return address(field, u)
37
+ } catch {
38
+ return u
39
+ }
40
+ }
41
+ return {
42
+ authorizationUrl: abs('authorizationUrl', flow.authorizationUrl),
43
+ tokenUrl: abs('tokenUrl', flow.tokenUrl),
44
+ ...(flow.scopes && Object.keys(flow.scopes).length ? { scopes: flow.scopes } : {}),
45
+ }
11
46
  }
12
47
 
13
48
  export interface SpecAuth {
@@ -16,6 +51,12 @@ export interface SpecAuth {
16
51
  secrets: Record<string, Record<string, never>>
17
52
  /** the profile requests use unless they say otherwise */
18
53
  defaultProfile?: string
54
+ /**
55
+ * Sign-in addresses the document gives relative to its servers become variables, so each environment
56
+ * signs in at its own server: the `live` values here, and one entry per other environment below.
57
+ */
58
+ vars: Record<string, string>
59
+ environmentVars: Record<string, Record<string, string>>
19
60
  }
20
61
 
21
62
  const secretName = (scheme: string) =>
@@ -28,15 +69,47 @@ const secretName = (scheme: string) =>
28
69
  * Turns a document's security schemes into auth profiles whose secrets are filled in at run time, so
29
70
  * previewing a protected API only needs the user to paste a key. OAuth-style schemes take an access token.
30
71
  */
31
- export function authFromSpec(api: any): SpecAuth {
72
+ export function authFromSpec(api: any, source?: string, servers?: ServerEntry[]): SpecAuth {
32
73
  const schemes: Record<string, any> =
33
74
  api.components?.securitySchemes ?? api.securityDefinitions ?? {}
34
- const out: SpecAuth = { auth: {}, secrets: {} }
75
+ const out: SpecAuth = { auth: {}, secrets: {}, vars: {}, environmentVars: {} }
76
+ const pageBase = source && isUrl(source) ? source : undefined
77
+ const resolve = (raw: string, base: string | undefined) => new URL(raw, base).toString()
78
+ // a relative address follows the server (OpenAPI 3.1): one variable per scheme and field, set per environment
79
+ const addressFor =
80
+ (scheme: string): Address =>
81
+ (field, raw) => {
82
+ if (!servers?.length || isUrl(raw)) return resolve(raw, pageBase)
83
+ const name = `${secretName(scheme)
84
+ .toLowerCase()
85
+ .replace(/_(.)/g, (_, c) => c.toUpperCase())}${field[0]?.toUpperCase()}${field.slice(1)}`
86
+ let any = false
87
+ for (const sv of servers) {
88
+ try {
89
+ const abs = resolve(raw, sv.url.endsWith('/') ? sv.url : `${sv.url}/`)
90
+ if (sv.name === 'live') out.vars[name] = abs
91
+ else out.environmentVars[sv.name] = { ...out.environmentVars[sv.name], [name]: abs }
92
+ any = true
93
+ } catch {
94
+ /* this server cannot resolve it */
95
+ }
96
+ }
97
+ if (!any) return resolve(raw, pageBase)
98
+ if (out.vars[name] === undefined) {
99
+ try {
100
+ out.vars[name] = resolve(raw, pageBase)
101
+ } catch {
102
+ out.vars[name] = raw
103
+ }
104
+ }
105
+ return `{{${name}}}`
106
+ }
35
107
  for (const [name, s] of Object.entries(schemes)) {
36
108
  const secret = secretName(name)
37
109
  const ref = `{{secrets.${secret}}}`
38
110
  let apply: JsonAuthProfile['apply'] | undefined
39
111
  let title = name
112
+ let oauth: OAuthSettings | undefined
40
113
  if (s.type === 'apiKey') {
41
114
  const key = s.name as string
42
115
  if (s.in === 'header') apply = { headers: { [key]: ref } }
@@ -55,10 +128,12 @@ export function authFromSpec(api: any): SpecAuth {
55
128
  title = `${name} (basic auth, paste base64 of user:password)`
56
129
  } else if (s.type === 'oauth2' || s.type === 'openIdConnect') {
57
130
  apply = { headers: { authorization: `Bearer ${ref}` } }
58
- title = `${name} (OAuth, paste an access token)`
131
+ const address = addressFor(name)
132
+ oauth = s.type === 'oauth2' ? codeFlow(s, address) : discovery(s, address)
133
+ title = `${name} (OAuth, ${oauth ? 'sign in or ' : ''}paste an access token)`
59
134
  }
60
135
  if (!apply) continue
61
- out.auth[name] = { title, apply }
136
+ out.auth[name] = { title, apply, ...(oauth ? { oauth } : {}) }
62
137
  out.secrets[secret] = {}
63
138
  }
64
139
  // the document's top-level `security` says which scheme most operations use
@@ -69,16 +144,138 @@ export function authFromSpec(api: any): SpecAuth {
69
144
  return out
70
145
  }
71
146
 
72
- /** Where the API lives, from `servers[0]` (OpenAPI 3) or `host`/`basePath` (Swagger 2). */
73
- export function liveBaseUrl(api: any, source: string): string | undefined {
74
- const server = api.servers?.[0]?.url as string | undefined
75
- if (server) {
76
- if (isUrl(server)) return server.replace(/\/$/, '')
77
- if (isUrl(source)) return new URL(server, source).toString().replace(/\/$/, '')
78
- return undefined
147
+ /** One server a spec lists, as an environment the UI can switch to. */
148
+ export interface ServerEntry {
149
+ /** the environment name: `live` for the first, else a slug of its description or host */
150
+ name: string
151
+ url: string
152
+ description?: string
153
+ }
154
+
155
+ /** `live` plus this many minus one: a spec with a server per region should not bury the picker. */
156
+ export const MAX_SERVERS = 20
157
+
158
+ const slug = (s: string) =>
159
+ s
160
+ .toLowerCase()
161
+ .replace(/[^a-z0-9]+/g, '-')
162
+ .replace(/^-+|-+$/g, '')
163
+ .slice(0, 24)
164
+ .replace(/-+$/, '')
165
+
166
+ /** An absolute address for one `servers` entry: variables at their defaults, a relative URL against `source`. */
167
+ function resolveServer(entry: any, source: string | undefined): string | undefined {
168
+ if (typeof entry?.url !== 'string') return undefined
169
+ const url = entry.url
170
+ .replace(/\{([^}]+)\}/g, (whole: string, key: string) => {
171
+ const d = entry.variables?.[key]?.default
172
+ return d === undefined || d === null ? whole : String(d)
173
+ })
174
+ .trim()
175
+ if (/\{[^}]*\}/.test(url)) return undefined
176
+ if (isUrl(url)) return url.replace(/\/+$/, '')
177
+ if (source) {
178
+ try {
179
+ return new URL(url, source).toString().replace(/\/+$/, '')
180
+ } catch {
181
+ /* not an address */
182
+ }
79
183
  }
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
184
  return undefined
84
185
  }
186
+
187
+ /**
188
+ * The servers a document lists, as environments. The first is `live` (what a single-server spec has always
189
+ * had); the rest are named from their description, else their host, and are always a valid environment name.
190
+ *
191
+ * `needsBaseUrl` is true when the first server is relative and `source` cannot resolve it: the person is
192
+ * asked for the address, and what they type is `live`. `omitted` counts usable servers beyond `MAX_SERVERS`.
193
+ */
194
+ export function openApiServers(
195
+ api: any,
196
+ source: string,
197
+ ): { servers: ServerEntry[]; needsBaseUrl: boolean; omitted: number } {
198
+ const src = isUrl(source) ? source : undefined
199
+ const listed: any[] = Array.isArray(api.servers) ? api.servers : []
200
+ const servers: ServerEntry[] = []
201
+ const describe = (e: any) =>
202
+ typeof e?.description === 'string' && e.description.trim() !== ''
203
+ ? { description: e.description.trim() as string }
204
+ : {}
205
+
206
+ if (listed.length === 0) {
207
+ // Swagger 2, or an OpenAPI 3 document that says nothing: the host, else wherever it was fetched from
208
+ if (api.host)
209
+ servers.push({
210
+ name: 'live',
211
+ url: `${api.schemes?.[0] ?? 'https'}://${api.host}${api.basePath ?? ''}`.replace(
212
+ /\/+$/,
213
+ '',
214
+ ),
215
+ })
216
+ else if (src) servers.push({ name: 'live', url: new URL(src).origin })
217
+ return { servers, needsBaseUrl: false, omitted: 0 }
218
+ }
219
+
220
+ const first = resolveServer(listed[0], src)
221
+ if (first) servers.push({ name: 'live', url: first, ...describe(listed[0]) })
222
+ const needsBaseUrl =
223
+ !first && typeof listed[0]?.url === 'string' && !/^https?:\/\//i.test(listed[0].url)
224
+
225
+ const taken = new Set(['mock', 'live'])
226
+ const seen = new Set(servers.map((x) => x.url))
227
+ let omitted = 0
228
+ for (const entry of listed.slice(1)) {
229
+ const url = resolveServer(entry, src)
230
+ if (!url || seen.has(url)) continue
231
+ if (servers.length >= MAX_SERVERS) {
232
+ omitted++
233
+ continue
234
+ }
235
+ seen.add(url)
236
+ const host = (() => {
237
+ try {
238
+ return new URL(url).host
239
+ } catch {
240
+ return ''
241
+ }
242
+ })()
243
+ const base = slug(describe(entry).description ?? '') || slug(host) || 'server'
244
+ let name = base
245
+ for (let n = 2; taken.has(name); n++) name = `${base}-${n}`
246
+ taken.add(name)
247
+ servers.push({ name, url, ...describe(entry) })
248
+ }
249
+ return { servers, needsBaseUrl, omitted }
250
+ }
251
+
252
+ /**
253
+ * What a document's servers become in a config: the first server's address as the variable `varName`, the
254
+ * others as the same variable in an environment of their own, and a `baseUrl` that reads it.
255
+ */
256
+ export function environmentsFromServers(
257
+ servers: ServerEntry[],
258
+ varName: string,
259
+ ): {
260
+ vars: Record<string, string>
261
+ environments: Record<string, { description?: string; vars?: Record<string, string> }>
262
+ baseUrl: string
263
+ } {
264
+ const vars: Record<string, string> = {}
265
+ const environments: Record<string, { description?: string; vars?: Record<string, string> }> = {}
266
+ for (const s of servers) {
267
+ const description = s.description ? { description: s.description } : {}
268
+ if (s.name === 'live') {
269
+ vars[varName] = s.url
270
+ if (s.description) environments.live = description
271
+ } else {
272
+ environments[s.name] = { ...description, vars: { [varName]: s.url } }
273
+ }
274
+ }
275
+ return { vars, environments, baseUrl: `{{${varName}}}` }
276
+ }
277
+
278
+ /** Where the API lives, from `servers[0]` (OpenAPI 3) or `host`/`basePath` (Swagger 2). */
279
+ export function liveBaseUrl(api: any, source: string): string | undefined {
280
+ return openApiServers(api, source).servers.find((s) => s.name === 'live')?.url
281
+ }
package/src/types.ts CHANGED
@@ -63,11 +63,6 @@ export type DeepPartial<T> = T extends readonly (infer U)[]
63
63
 
64
64
  export type Source<T> = Templated<T> | (() => Promise<{ default: Templated<T> }>)
65
65
 
66
- /** Mock overrides are deep-merged over the generated example, so they only need the fields that matter. */
67
- export type PartialSource<T> =
68
- | DeepPartial<Templated<T>>
69
- | (() => Promise<{ default: DeepPartial<Templated<T>> }>)
70
-
71
66
  export type Init = Record<string, unknown>
72
67
  export type ResolvedInit<I> = { [K in keyof I]: I[K] extends () => infer R ? R : I[K] }
73
68
 
@@ -109,6 +104,10 @@ export interface RequestInput<T extends OpTypes, I extends Init = Init, C = {}>
109
104
  data?: Source<T['body']>
110
105
  encoding?: Encoding
111
106
  timeoutMs?: number
107
+ /** A request that only reads (a POST search, a GraphQL query): allowed in a read-only run. */
108
+ safe?: boolean
109
+ /** Labels to select this request by (`specbook test --tag monitor`) and to group it by in the UI. */
110
+ tags?: string[]
112
111
  capture?: { [K in keyof C]: (res: ResponseOf<T>, vars: ResolvedInit<I>) => C[K] }
113
112
  assert?: (
114
113
  res: ResponseOf<T>,
@@ -120,39 +119,50 @@ export interface RequestInput<T extends OpTypes, I extends Init = Init, C = {}>
120
119
  req: RenderedRequest
121
120
  },
122
121
  ) => void | Promise<void>
123
- mock?: MockInput<T, I>
124
122
  auth?: AuthProfileName | false
125
123
  assertMode?: AssertMode
126
124
  }
127
125
 
128
- export interface MockResponse<B> {
129
- data?: PartialSource<B>
130
- init?: Init
131
- headers?: Record<string, string>
132
- delayMs?: number
126
+ /** What the request being answered looks like to a mock: its values, as they were sent. */
127
+ export interface MockReq<I> {
128
+ vars: ResolvedInit<I> & Record<string, unknown>
129
+ body: unknown
130
+ query: unknown
131
+ path: unknown
132
+ headers: Record<string, string>
133
133
  }
134
134
 
135
- export interface MockInput<T extends OpTypes, I> {
136
- /**
137
- * Echo request values into the generated response (fields it shares with the body, the path id).
138
- * Default true; explicit `responses` always win.
139
- */
140
- reflect?: boolean
141
- status?: keyof T['responses'] & number
142
- responses?: {
143
- [S in keyof T['responses'] & number]?:
144
- | PartialSource<T['responses'][S]>
145
- | MockResponse<T['responses'][S]>
135
+ /**
136
+ * The answer a mock is about to give, one shape per status the operation declares so that checking `res.status` narrows
137
+ * `res.data`. Change `data`, `headers` and `delayMs` in place, or `respond` with another status.
138
+ */
139
+ export type MockRes<T extends OpTypes> = {
140
+ [S in keyof T['responses'] & number]: {
141
+ status: S
142
+ data: T['responses'][S]
143
+ headers: Record<string, string>
144
+ /** Wait this long before answering. */
145
+ delayMs: number
146
+ /**
147
+ * Answer with another status the operation declares: its generated example, with `data` merged over it. Request values
148
+ * are echoed into a 2xx example as usual.
149
+ */
150
+ respond<R extends keyof T['responses'] & number>(
151
+ status: R,
152
+ data?: DeepPartial<T['responses'][R]>,
153
+ ): void
146
154
  }
147
- when?: (req: {
148
- vars: ResolvedInit<I>
149
- body: unknown
150
- query: unknown
151
- path: unknown
152
- }) => (keyof T['responses'] & number) | undefined
153
- }
155
+ }[keyof T['responses'] & number]
156
+
157
+ /** Runs when the `mock` environment answers this request, after the example is chosen, filled from the request and rendered. */
158
+ export type MockHook<T extends OpTypes, I> = (
159
+ req: MockReq<I>,
160
+ res: MockRes<T>,
161
+ ) => void | Promise<void>
154
162
 
155
163
  export interface RequestOverrides<T extends OpTypes, I, C2> {
164
+ /** Added to the request's own tags. */
165
+ tags?: string[]
156
166
  vars?: Partial<ResolvedInit<I>> & Record<string, unknown>
157
167
  path?: Partial<Templated<T['params']['path']>>
158
168
  query?: Partial<Templated<T['params']['query']>>
@@ -160,7 +170,6 @@ export interface RequestOverrides<T extends OpTypes, I, C2> {
160
170
  data?: DeepPartial<Templated<T['body']>>
161
171
  capture?: { [K in keyof C2]: (res: ResponseOf<T>) => C2[K] }
162
172
  assert?: RequestInput<T, any, any>['assert']
163
- mock?: { status?: keyof T['responses'] & number }
164
173
  auth?: AuthProfileName | false
165
174
  assertMode?: AssertMode
166
175
  }
@@ -171,8 +180,10 @@ export interface RequestDef<T extends OpTypes = AnyOp, I extends Init = Init, C
171
180
  readonly input: RequestInput<T, I, C>
172
181
  /** Deep-merge patches applied to the rendered data template, in order. */
173
182
  readonly dataPatches: readonly unknown[]
174
- /** Explicit `.with({ vars })`; beats captures and params, only UI overrides are higher. */
183
+ /** Explicit `.with({ vars })`; beats captures and inputs, only UI overrides are higher. */
175
184
  readonly varOverrides: Readonly<Record<string, unknown>>
185
+ /** Set by `withMock`: how the mock environment answers this request. */
186
+ readonly mockHook?: MockHook<T, I>
176
187
  /** Phantom: lets sequence steps infer the captured type. */
177
188
  readonly _captured?: C
178
189
  with<C2 = {}>(o: RequestOverrides<T, I, C2>): RequestDef<T, I, C & C2>
@@ -208,7 +219,7 @@ export interface RenderedRequest {
208
219
  /** Where a placeholder's value came from, highest precedence first. */
209
220
  export interface VarUse {
210
221
  key: string
211
- source: 'override' | 'capture' | 'param' | 'init' | 'config'
222
+ source: 'override' | 'capture' | 'input' | 'init' | 'config'
212
223
  /** Step that produced it, for captures. */
213
224
  step?: string
214
225
  /** A capture replaced a value of another type (e.g. a string id in an integer slot). */
@@ -241,6 +252,12 @@ export interface RunResult {
241
252
  seed?: number
242
253
  usedDrafts: boolean
243
254
  tags: string[]
255
+ /** The project file the request or flow comes from, relative to the project; set by the CLI. */
256
+ file?: string
257
+ /** How many times it was run when retries were allowed (1 = no retry); set by the CLI. */
258
+ attempts?: number
259
+ /** It failed, then passed on a retry; set by the CLI. */
260
+ flaky?: boolean
244
261
  status: 'pass' | 'fail' | 'error'
245
262
  startedAt: string
246
263
  durationMs: number