@specbook/core 0.2.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.
@@ -16,11 +16,11 @@ export interface PreparedRequest {
16
16
  /** Array style per form field, from the spec's `encoding` object. */
17
17
  formArrays?: Record<string, FormArrays>
18
18
  timeoutMs?: number
19
- /** Mock status the caller wants served (mock env only). */
19
+ /** Mock status the caller wants served, e.g. the UI's "Respond with" (mock env only). */
20
20
  mockStatus?: number
21
21
  /** In-process hints for the mock transport; never serialized or substituted. */
22
22
  mock?: {
23
- config?: import('../types').MockInput<any, any>
23
+ hook?: import('../types').MockHook<any, any>
24
24
  vars: Record<string, unknown>
25
25
  pathParams: Record<string, unknown>
26
26
  rawQuery: Record<string, unknown>
@@ -46,6 +46,18 @@ export function buildUrl(base: string, req: PreparedRequest): string {
46
46
  return `${base.replace(/\/+$/, '')}${req.url}${q ? `?${q}` : ''}`
47
47
  }
48
48
 
49
+ /** A config `baseUrl` may be relative (`/api/v1`): it is resolved against the page. */
50
+ export function absoluteBase(baseUrl: string, pageHref?: string): string {
51
+ try {
52
+ return new URL(baseUrl, pageHref).toString()
53
+ } catch {
54
+ return baseUrl
55
+ }
56
+ }
57
+
58
+ /** How long a request waits for an answer when nothing says otherwise. */
59
+ export const DEFAULT_TIMEOUT_MS = 30_000
60
+
49
61
  export interface FetchTransportOptions {
50
62
  baseUrl: (service: string) => string
51
63
  fetch?: typeof fetch
@@ -53,6 +65,8 @@ export interface FetchTransportOptions {
53
65
 
54
66
  export function createFetchTransport(opts: FetchTransportOptions): Transport {
55
67
  return async (req, signal) => {
68
+ // a run stopped before this request left: nothing is sent, and no 'abort' will ever be heard from a signal that has fired
69
+ if (signal?.aborted) throw new Error('Stopped before the API answered.')
56
70
  const f = opts.fetch ?? fetch
57
71
  const headers: Record<string, string> = { ...req.headers }
58
72
  let body: BodyInit | undefined
@@ -61,10 +75,15 @@ export function createFetchTransport(opts: FetchTransportOptions): Transport {
61
75
  body = enc.body
62
76
  if (enc.contentType) headers['content-type'] ??= enc.contentType
63
77
  }
64
- if (req.mockStatus !== undefined) headers['x-specbook-mock-status'] = String(req.mockStatus)
65
78
 
66
79
  const ctl = new AbortController()
67
- const timer = req.timeoutMs ? setTimeout(() => ctl.abort(), req.timeoutMs) : undefined
80
+ let timedOut = false
81
+ const timer = req.timeoutMs
82
+ ? setTimeout(() => {
83
+ timedOut = true
84
+ ctl.abort()
85
+ }, req.timeoutMs)
86
+ : undefined
68
87
  signal?.addEventListener('abort', () => ctl.abort())
69
88
  try {
70
89
  const res = await f(buildUrl(opts.baseUrl(req.service), req), {
@@ -88,6 +107,12 @@ export function createFetchTransport(opts: FetchTransportOptions): Transport {
88
107
  }
89
108
  }
90
109
  return { status: res.status, headers: outHeaders, text, body: parsed }
110
+ } catch (e) {
111
+ // an abort that the request's own timeout caused says so, not "This operation was aborted"
112
+ if (timedOut) throw new Error(`Timed out after ${req.timeoutMs}ms: no answer from the API.`)
113
+ // a person pressing Stop, or the run's own deadline: say so, not "This operation was aborted"
114
+ if (signal?.aborted) throw new Error('Stopped before the API answered.')
115
+ throw e
91
116
  } finally {
92
117
  if (timer) clearTimeout(timer)
93
118
  }
package/src/secrets.ts CHANGED
@@ -67,9 +67,38 @@ export async function resolveSecrets(
67
67
  return out
68
68
  }
69
69
 
70
- /** Masks with `***`, longest first so a secret containing another is hidden whole; values under 4 chars are skipped to avoid masking ordinary text */
70
+ /** Secrets shorter than this are not masked: they would blank out ordinary text. */
71
+ const MIN_MASKED = 3
72
+
73
+ /**
74
+ * A secret as it can appear in a URL, a JSON string or a Basic-auth style base64 value, besides itself: what a server
75
+ * echoes back or an error message quotes is not always the raw value.
76
+ */
77
+ export function maskVariants(secret: string): string[] {
78
+ const out = new Set<string>([secret])
79
+ const add = (f: () => string) => {
80
+ try {
81
+ out.add(f())
82
+ } catch {
83
+ /* not encodable: the raw value is still masked */
84
+ }
85
+ }
86
+ add(() => encodeURIComponent(secret))
87
+ add(() => encodeURI(secret))
88
+ add(() => JSON.stringify(secret).slice(1, -1))
89
+ add(() => btoa(String.fromCharCode(...new TextEncoder().encode(secret))))
90
+ return [...out]
91
+ }
92
+
93
+ /**
94
+ * Masks with `***`, longest first so a secret containing another is hidden whole. Every secret is masked in its
95
+ * raw, URL-encoded, JSON-escaped and base64 forms; values under 3 characters are skipped to avoid masking
96
+ * ordinary text.
97
+ */
71
98
  export function maskDeep<T>(value: T, secrets: Iterable<string>): T {
72
- const list = [...secrets].filter((s) => s.length >= 4).sort((a, b) => b.length - a.length)
99
+ const list = [
100
+ ...new Set([...secrets].filter((s) => s.length >= MIN_MASKED).flatMap(maskVariants)),
101
+ ].sort((a, b) => b.length - a.length)
73
102
  if (list.length === 0) return value
74
103
  const maskStr = (s: string) => list.reduce((acc, v) => acc.split(v).join('***'), s)
75
104
  const walk = (v: unknown): unknown => {
package/src/sequence.ts CHANGED
@@ -2,7 +2,7 @@ import type { Controls, InferControls } from './controls'
2
2
  import type { Assert, AssertMode, AuthProfileName, RequestDef } from './types'
3
3
 
4
4
  export interface StepCtx<P, C> {
5
- params: P
5
+ inputs: P
6
6
  captured: C
7
7
  vars: Record<string, unknown>
8
8
  env: string
@@ -34,7 +34,7 @@ export interface SequenceMeta {
34
34
  tags?: string[]
35
35
  /** Heading the flow is listed under in the catalog; flows without one are under "Default". */
36
36
  group?: string
37
- params?: Controls
37
+ inputs?: Controls
38
38
  auth?: AuthProfileName | false
39
39
  assertMode?: AssertMode
40
40
  continueOnFailure?: boolean
@@ -55,7 +55,7 @@ export interface Sequence<P = {}, C = {}> {
55
55
  name: string,
56
56
  fn: (ctx: StepCtx<P, C>, h: { assert: Assert }) => void | Promise<void>,
57
57
  ): Sequence<P, C>
58
- /** Pause for user input; the chosen values merge into ctx.params. */
58
+ /** Pause for user input; the chosen values merge into ctx.inputs. */
59
59
  input<R extends Controls>(
60
60
  name: string,
61
61
  controls: (ctx: StepCtx<P, C>) => R,
@@ -77,7 +77,7 @@ function build(meta: SequenceMeta, nodes: readonly StepNode[]): Sequence<any, an
77
77
  }
78
78
 
79
79
  export function sequence<P extends Controls = {}>(
80
- meta: Omit<SequenceMeta, 'params'> & { params?: P },
80
+ meta: Omit<SequenceMeta, 'inputs'> & { inputs?: P },
81
81
  ): Sequence<InferControls<P>, {}> {
82
82
  return build(meta, [])
83
83
  }
@@ -14,30 +14,33 @@ export interface JsonAuthProfile {
14
14
  oauth?: OAuthSettings
15
15
  }
16
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
+
17
20
  /** An OpenID Connect scheme: the discovery document says where to sign in. */
18
- function discovery(s: any, base: string | undefined): OAuthSettings | undefined {
21
+ function discovery(s: any, address: Address): OAuthSettings | undefined {
19
22
  if (typeof s.openIdConnectUrl !== 'string' || s.openIdConnectUrl === '') return undefined
20
23
  try {
21
- return { openIdConnectUrl: new URL(s.openIdConnectUrl, base).toString() }
24
+ return { openIdConnectUrl: address('openIdConnectUrl', s.openIdConnectUrl) }
22
25
  } catch {
23
26
  return undefined
24
27
  }
25
28
  }
26
29
 
27
30
  /** 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 {
31
+ function codeFlow(s: any, address: Address): OAuthSettings | undefined {
29
32
  const flow = s.flows?.authorizationCode ?? (s.flow === 'accessCode' ? s : undefined)
30
33
  if (!flow?.authorizationUrl || !flow?.tokenUrl) return undefined
31
- const abs = (u: string) => {
34
+ const abs = (field: 'authorizationUrl' | 'tokenUrl', u: string) => {
32
35
  try {
33
- return new URL(u, base).toString()
36
+ return address(field, u)
34
37
  } catch {
35
38
  return u
36
39
  }
37
40
  }
38
41
  return {
39
- authorizationUrl: abs(flow.authorizationUrl),
40
- tokenUrl: abs(flow.tokenUrl),
42
+ authorizationUrl: abs('authorizationUrl', flow.authorizationUrl),
43
+ tokenUrl: abs('tokenUrl', flow.tokenUrl),
41
44
  ...(flow.scopes && Object.keys(flow.scopes).length ? { scopes: flow.scopes } : {}),
42
45
  }
43
46
  }
@@ -48,6 +51,12 @@ export interface SpecAuth {
48
51
  secrets: Record<string, Record<string, never>>
49
52
  /** the profile requests use unless they say otherwise */
50
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>>
51
60
  }
52
61
 
53
62
  const secretName = (scheme: string) =>
@@ -60,10 +69,41 @@ const secretName = (scheme: string) =>
60
69
  * Turns a document's security schemes into auth profiles whose secrets are filled in at run time, so
61
70
  * previewing a protected API only needs the user to paste a key. OAuth-style schemes take an access token.
62
71
  */
63
- export function authFromSpec(api: any, source?: string): SpecAuth {
72
+ export function authFromSpec(api: any, source?: string, servers?: ServerEntry[]): SpecAuth {
64
73
  const schemes: Record<string, any> =
65
74
  api.components?.securitySchemes ?? api.securityDefinitions ?? {}
66
- 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
+ }
67
107
  for (const [name, s] of Object.entries(schemes)) {
68
108
  const secret = secretName(name)
69
109
  const ref = `{{secrets.${secret}}}`
@@ -88,8 +128,8 @@ export function authFromSpec(api: any, source?: string): SpecAuth {
88
128
  title = `${name} (basic auth, paste base64 of user:password)`
89
129
  } else if (s.type === 'oauth2' || s.type === 'openIdConnect') {
90
130
  apply = { headers: { authorization: `Bearer ${ref}` } }
91
- const base = source && isUrl(source) ? source : undefined
92
- oauth = s.type === 'oauth2' ? codeFlow(s, base) : discovery(s, base)
131
+ const address = addressFor(name)
132
+ oauth = s.type === 'oauth2' ? codeFlow(s, address) : discovery(s, address)
93
133
  title = `${name} (OAuth, ${oauth ? 'sign in or ' : ''}paste an access token)`
94
134
  }
95
135
  if (!apply) continue
@@ -209,6 +249,32 @@ export function openApiServers(
209
249
  return { servers, needsBaseUrl, omitted }
210
250
  }
211
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
+
212
278
  /** Where the API lives, from `servers[0]` (OpenAPI 3) or `host`/`basePath` (Swagger 2). */
213
279
  export function liveBaseUrl(api: any, source: string): string | undefined {
214
280
  return openApiServers(api, source).servers.find((s) => s.name === 'live')?.url
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