@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.
package/src/mock.ts CHANGED
@@ -1,8 +1,8 @@
1
- // In-process mock transport: serves request-level mock overrides, falling back to generated examples.
1
+ // In-process mock transport: answers from the generated examples, filled in from the request that was sent, and lets a
2
+ // request's `withMock` hook change the answer.
2
3
  import { reflectRequest } from './mock-reflect'
3
4
  import type { PreparedRequest, RawResponse, Transport } from './runner/transport'
4
5
  import { deepMerge, render } from './template'
5
- import type { MockResponse } from './types'
6
6
 
7
7
  export interface MockExampleLoader {
8
8
  /** Statuses that have an example for this operation, in spec order. */
@@ -12,48 +12,35 @@ export interface MockExampleLoader {
12
12
 
13
13
  export interface MockTransportOptions {
14
14
  loader: MockExampleLoader
15
- sleep?: (ms: number) => Promise<void>
15
+ /** Waits a mock's `delayMs`. The default can be ended early by the run's signal: a person pressing Stop. */
16
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>
16
17
  }
17
18
 
18
- const isMockResponse = (v: unknown): v is MockResponse<unknown> =>
19
- v !== null &&
20
- typeof v === 'object' &&
21
- ('data' in (v as object) ||
22
- 'init' in (v as object) ||
23
- 'headers' in (v as object) ||
24
- 'delayMs' in (v as object))
19
+ const is2xx = (s: number) => s >= 200 && s < 300
25
20
 
26
- function evalInit(init: Record<string, unknown> | undefined) {
27
- return Object.fromEntries(
28
- Object.entries(init ?? {}).map(([k, v]) => [
29
- k,
30
- typeof v === 'function' ? (v as () => unknown)() : v,
31
- ]),
32
- )
33
- }
21
+ /** The loader may hand out one object to every caller, and a hook edits what it is given. */
22
+ const copy = <T>(v: T): T => (v === undefined ? v : (JSON.parse(JSON.stringify(v)) as T))
34
23
 
35
24
  export function createMockTransport(opts: MockTransportOptions): Transport {
36
- const sleep = opts.sleep ?? ((ms) => new Promise<void>((r) => setTimeout(r, ms)))
37
- return async (req: PreparedRequest): Promise<RawResponse> => {
25
+ const sleep =
26
+ opts.sleep ??
27
+ ((ms: number, signal?: AbortSignal) =>
28
+ new Promise<void>((done, stop) => {
29
+ if (signal?.aborted) return stop(new Error('Stopped before the API answered.'))
30
+ const timer = setTimeout(done, ms)
31
+ signal?.addEventListener(
32
+ 'abort',
33
+ () => {
34
+ clearTimeout(timer)
35
+ stop(new Error('Stopped before the API answered.'))
36
+ },
37
+ { once: true },
38
+ )
39
+ }))
40
+ return async (req: PreparedRequest, signal?: AbortSignal): Promise<RawResponse> => {
38
41
  const hints = req.mock
39
- const cfg = hints?.config
40
- const known = await opts.loader.statuses(req.service, req.opId)
41
- const overridden = Object.keys(cfg?.responses ?? {}).map(Number)
42
- const all = [...new Set([...known, ...overridden])]
43
-
44
- const fromWhen = cfg?.when?.({
45
- vars: (hints?.vars ?? {}) as never,
46
- body: req.body,
47
- query: hints?.rawQuery,
48
- path: hints?.pathParams,
49
- })
50
- const status =
51
- req.mockStatus ??
52
- fromWhen ??
53
- cfg?.status ??
54
- all.find((s) => s >= 200 && s < 300) ??
55
- all[0] ??
56
- 200
42
+ const all = await opts.loader.statuses(req.service, req.opId)
43
+ const status = req.mockStatus ?? all.find(is2xx) ?? all[0] ?? 200
57
44
 
58
45
  // Secured operations answer 401 when no credential header was applied.
59
46
  if (
@@ -67,42 +54,65 @@ export function createMockTransport(opts: MockTransportOptions): Transport {
67
54
  return json(401, body)
68
55
  }
69
56
 
70
- const override = (cfg?.responses as Record<number, unknown> | undefined)?.[status]
71
- let delay = 0
72
- let headers: Record<string, string> = {}
73
- let scope = { ...(hints?.vars ?? {}), ...(hints?.pathParams ?? {}), ...(hints?.rawQuery ?? {}) }
74
- let template: unknown = await opts.loader.example(req.service, req.opId, status)
57
+ const scope = {
58
+ ...(hints?.vars ?? {}),
59
+ ...(hints?.pathParams ?? {}),
60
+ ...(hints?.rawQuery ?? {}),
61
+ }
75
62
  // The generated example answers the request that was sent, so a created thing has the fields you posted.
76
- if (status >= 200 && status < 300 && cfg?.reflect !== false)
77
- template = reflectRequest(template, {
78
- body: req.body,
79
- path: hints?.pathParams,
80
- query: hints?.rawQuery,
81
- })
82
63
  // A declared status without a response schema (e.g. Petstore's 404) has no example: empty body.
83
- if (override !== undefined) {
84
- // Overrides are layered over the generated example (objects merge, arrays/scalars replace).
85
- let data: unknown
86
- if (isMockResponse(override)) {
87
- data = await resolveSource(override.data)
88
- scope = { ...scope, ...evalInit(override.init) }
89
- headers = override.headers ? render(override.headers, scope) : {}
90
- delay = override.delayMs ?? 0
91
- } else {
92
- data = await resolveSource(override)
93
- }
94
- if (data !== undefined) template = deepMerge(template, data)
64
+ const answer = async (s: number) => {
65
+ let template: unknown = copy(await opts.loader.example(req.service, req.opId, s))
66
+ if (is2xx(s))
67
+ template = reflectRequest(template, {
68
+ body: req.body,
69
+ path: hints?.pathParams,
70
+ query: hints?.rawQuery,
71
+ })
72
+ return template === undefined ? undefined : render(template, scope)
73
+ }
74
+
75
+ const res: {
76
+ status: number
77
+ data: unknown
78
+ headers: Record<string, string>
79
+ delayMs: number
80
+ respond(status: number, data?: unknown): void
81
+ } = {
82
+ status,
83
+ data: await answer(status),
84
+ headers: {},
85
+ delayMs: 0,
86
+ respond: (s, data) => {
87
+ pending = { status: s, data }
88
+ },
89
+ }
90
+ let pending: { status: number; data?: unknown } | undefined
91
+
92
+ if (hints?.hook) {
93
+ await hints.hook(
94
+ {
95
+ vars: (hints.vars ?? {}) as never,
96
+ body: req.body,
97
+ query: hints.rawQuery,
98
+ path: hints.pathParams,
99
+ headers: req.headers,
100
+ },
101
+ res as never,
102
+ )
103
+ }
104
+ if (pending) {
105
+ const base = await answer(pending.status)
106
+ res.status = pending.status
107
+ res.data = pending.data === undefined ? base : deepMerge(base, pending.data)
95
108
  }
96
- if (delay) await sleep(delay)
97
- const body = template === undefined ? undefined : render(template, scope)
98
- return { ...json(status, body), headers: { 'content-type': 'application/json', ...headers } }
99
- }
100
- }
101
109
 
102
- async function resolveSource(src: unknown): Promise<unknown> {
103
- if (typeof src === 'function')
104
- return (await (src as () => Promise<{ default: unknown }>)()).default
105
- return src
110
+ if (res.delayMs) await sleep(res.delayMs, signal)
111
+ return {
112
+ ...json(res.status, res.data),
113
+ headers: { 'content-type': 'application/json', ...res.headers },
114
+ }
115
+ }
106
116
  }
107
117
 
108
118
  function json(status: number, body: unknown): RawResponse {
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
+ }
@@ -0,0 +1,53 @@
1
+ // Where a run happens, from the environment a CI system sets, for the report's header and its links.
2
+
3
+ export interface CiInfo {
4
+ provider: string
5
+ runUrl?: string
6
+ sha?: string
7
+ branch?: string
8
+ }
9
+
10
+ export function detectCi(env: Record<string, string | undefined>): CiInfo | undefined {
11
+ if (env.GITHUB_ACTIONS === 'true') {
12
+ const server = env.GITHUB_SERVER_URL ?? 'https://github.com'
13
+ return {
14
+ provider: 'github-actions',
15
+ runUrl:
16
+ env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID
17
+ ? `${server}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`
18
+ : undefined,
19
+ sha: env.GITHUB_SHA,
20
+ branch: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME,
21
+ }
22
+ }
23
+ if (env.GITLAB_CI === 'true')
24
+ return {
25
+ provider: 'gitlab-ci',
26
+ runUrl: env.CI_PIPELINE_URL,
27
+ sha: env.CI_COMMIT_SHA,
28
+ branch: env.CI_COMMIT_REF_NAME,
29
+ }
30
+ if (env.CIRCLECI === 'true')
31
+ return {
32
+ provider: 'circleci',
33
+ runUrl: env.CIRCLE_BUILD_URL,
34
+ sha: env.CIRCLE_SHA1,
35
+ branch: env.CIRCLE_BRANCH,
36
+ }
37
+ if (env.BUILDKITE === 'true')
38
+ return {
39
+ provider: 'buildkite',
40
+ runUrl: env.BUILDKITE_BUILD_URL,
41
+ sha: env.BUILDKITE_COMMIT,
42
+ branch: env.BUILDKITE_BRANCH,
43
+ }
44
+ if (env.JENKINS_URL)
45
+ return {
46
+ provider: 'jenkins',
47
+ runUrl: env.BUILD_URL,
48
+ sha: env.GIT_COMMIT,
49
+ branch: env.GIT_BRANCH,
50
+ }
51
+ if (env.CI) return { provider: 'ci' }
52
+ return undefined
53
+ }
@@ -0,0 +1,34 @@
1
+ // Terminal colors, only when they will be seen: a TTY or a CI log that renders them, not NO_COLOR, or FORCE_COLOR.
2
+
3
+ export interface Colors {
4
+ red(s: string): string
5
+ green(s: string): string
6
+ yellow(s: string): string
7
+ cyan(s: string): string
8
+ dim(s: string): string
9
+ bold(s: string): string
10
+ }
11
+
12
+ /** CI systems whose logs render color though the stream is not a terminal. */
13
+ const COLOR_CI = ['GITHUB_ACTIONS', 'GITLAB_CI', 'CIRCLECI', 'BUILDKITE']
14
+
15
+ /** Whether to color output: FORCE_COLOR wins, then NO_COLOR, then an interactive terminal or a CI log that shows color. */
16
+ export function colorEnabled(env: Record<string, string | undefined>, isTTY: boolean): boolean {
17
+ if (env.FORCE_COLOR && env.FORCE_COLOR !== '0') return true
18
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== '') return false
19
+ if (COLOR_CI.some((k) => env[k] && env[k] !== 'false')) return true
20
+ return isTTY && env.TERM !== 'dumb'
21
+ }
22
+
23
+ export function createColors(enabled: boolean): Colors {
24
+ const wrap = (open: number, close: number) => (s: string) =>
25
+ enabled ? `\u001b[${open}m${s}\u001b[${close}m` : s
26
+ return {
27
+ red: wrap(31, 39),
28
+ green: wrap(32, 39),
29
+ yellow: wrap(33, 39),
30
+ cyan: wrap(36, 39),
31
+ dim: wrap(2, 22),
32
+ bold: wrap(1, 22),
33
+ }
34
+ }
@@ -0,0 +1,63 @@
1
+ // The JSON report: the results with what a monitoring system needs to know about the run around them.
2
+
3
+ import type { RunResult } from '../types'
4
+ import type { CiInfo } from './ci'
5
+
6
+ export interface ReportMeta {
7
+ env: string
8
+ /** version of the specbook that ran it */
9
+ specbook?: string
10
+ node?: string
11
+ os?: string
12
+ git?: { sha?: string; branch?: string }
13
+ ci?: CiInfo
14
+ /** `bundle.json` details when run from a bundle */
15
+ bundle?: { sha?: string; createdAt?: string }
16
+ /** service → origin of the address its calls went to */
17
+ services?: Record<string, string>
18
+ startedAt: string
19
+ finishedAt: string
20
+ }
21
+
22
+ export interface ReportTotals {
23
+ flows: { passed: number; failed: number; errored: number; flaky: number }
24
+ steps: { passed: number; failed: number; skipped: number }
25
+ assertions: { passed: number; failed: number }
26
+ }
27
+
28
+ export interface RunReport extends ReportMeta {
29
+ schemaVersion: 2
30
+ durationMs: number
31
+ totals: ReportTotals
32
+ results: RunResult[]
33
+ }
34
+
35
+ export function reportTotals(results: RunResult[]): ReportTotals {
36
+ const t: ReportTotals = {
37
+ flows: { passed: 0, failed: 0, errored: 0, flaky: 0 },
38
+ steps: { passed: 0, failed: 0, skipped: 0 },
39
+ assertions: { passed: 0, failed: 0 },
40
+ }
41
+ for (const r of results) {
42
+ if (r.status === 'pass') t.flows.passed++
43
+ else if (r.status === 'error') t.flows.errored++
44
+ else t.flows.failed++
45
+ if (r.flaky) t.flows.flaky++
46
+ t.steps.passed += r.totals.passed
47
+ t.steps.failed += r.totals.failed
48
+ t.steps.skipped += r.totals.skipped
49
+ t.assertions.passed += r.totals.assertions.passed
50
+ t.assertions.failed += r.totals.assertions.failed
51
+ }
52
+ return t
53
+ }
54
+
55
+ export function buildReport(results: RunResult[], meta: ReportMeta): RunReport {
56
+ return {
57
+ schemaVersion: 2,
58
+ ...meta,
59
+ durationMs: Math.max(0, Date.parse(meta.finishedAt) - Date.parse(meta.startedAt)),
60
+ totals: reportTotals(results),
61
+ results,
62
+ }
63
+ }