@specbook/core 0.3.0 → 0.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specbook/core",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "The runner, mock engine, template engine and shared proxy behind Specbook: Storybook for APIs.",
5
5
  "keywords": [
6
6
  "specbook",
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export {
33
33
  deepMerge,
34
34
  render,
35
35
  renderString,
36
+ rootName,
36
37
  TemplateError,
37
38
  usesFaker,
38
39
  } from './template'
@@ -54,7 +54,7 @@ export interface JUnitOptions {
54
54
  hostname?: string
55
55
  /** shown as `<properties>` on each suite (env, git sha, seed…) */
56
56
  properties?: Record<string, string | undefined>
57
- /** service → origin, for the trace in `<system-out>` */
57
+ /** service → the address its calls go to (origin and base path), for the trace in `<system-out>` */
58
58
  origins?: Record<string, string>
59
59
  }
60
60
 
@@ -13,7 +13,7 @@ export interface ListOptions {
13
13
  quiet?: boolean
14
14
  /** show the request and response of a failed step (default), of every step, or not at all */
15
15
  trace?: 'on-failure' | 'always' | 'off'
16
- /** service → origin, to show full addresses in a trace */
16
+ /** service → the address its calls go to (origin and base path), so a trace shows the URL that was called */
17
17
  origins?: Record<string, string>
18
18
  /** paths are shown relative to this folder */
19
19
  cwd?: string
@@ -16,7 +16,7 @@ const SHOWN_RESPONSE_HEADERS = [
16
16
  ]
17
17
 
18
18
  export interface TraceOptions {
19
- /** service → origin (`https://api.example.com`), to show the full address */
19
+ /** service → the address its calls go to (`https://api.example.com/v1`: origin and base path), to show the full URL */
20
20
  origins?: Record<string, string>
21
21
  /** characters of a body shown before it is cut (default 2048) */
22
22
  maxBody?: number
package/src/runner/run.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  isFakerName,
12
12
  isPlainObject,
13
13
  render,
14
+ rootName,
14
15
  } from '../template'
15
16
  import type {
16
17
  ApiResponse,
@@ -201,7 +202,7 @@ function usesOf(
201
202
  const out: VarUse[] = []
202
203
  for (const name of names) {
203
204
  if (isDeferred(name) || isFakerName(name)) continue
204
- const key = name.split('.')[0] as string
205
+ const key = rootName(name)
205
206
  if (seen.has(key)) continue
206
207
  seen.add(key)
207
208
  for (let i = layers.length - 1; i >= 0; i--) {
@@ -58,6 +58,40 @@ export function absoluteBase(baseUrl: string, pageHref?: string): string {
58
58
  /** How long a request waits for an answer when nothing says otherwise. */
59
59
  export const DEFAULT_TIMEOUT_MS = 30_000
60
60
 
61
+ /** What node's fetch says in `error.cause.code`, in words a person can act on. */
62
+ const WHY: Record<string, string> = {
63
+ ECONNREFUSED: 'connection refused',
64
+ ENOTFOUND: 'host not found',
65
+ EAI_AGAIN: 'host not found',
66
+ ECONNRESET: 'connection reset',
67
+ ETIMEDOUT: 'connection timed out',
68
+ UND_ERR_CONNECT_TIMEOUT: 'connection timed out',
69
+ EHOSTUNREACH: 'host unreachable',
70
+ ENETUNREACH: 'network unreachable',
71
+ }
72
+
73
+ /** node's bare "fetch failed" says nothing; its cause says why. A browser gives no cause, so its error is left as it is. */
74
+ function unreachable(e: unknown, url: string): unknown {
75
+ const cause = (
76
+ e as { cause?: { code?: string; message?: string; errors?: { code?: string }[] } } | undefined
77
+ )?.cause
78
+ if (!(e instanceof TypeError) || !cause) return e
79
+ const code = cause.code ?? cause.errors?.find((x) => x.code)?.code
80
+ const tls =
81
+ code && /^CERT_|^ERR_TLS|SELF_SIGNED|UNABLE_TO_VERIFY/.test(code) ? 'certificate problem' : ''
82
+ // a reason there are words for, else the cause's own message ("bad port"), else nothing to add
83
+ const own = typeof cause.message === 'string' && cause.message.length <= 120 ? cause.message : ''
84
+ const why = (code && WHY[code]) || tls || own
85
+ if (!why) return e
86
+ let host = url
87
+ try {
88
+ host = new URL(url).host
89
+ } catch {
90
+ /* an address that is not a URL is shown as it is */
91
+ }
92
+ return new Error(`Could not reach ${host}: ${why}.`, { cause: e })
93
+ }
94
+
61
95
  export interface FetchTransportOptions {
62
96
  baseUrl: (service: string) => string
63
97
  fetch?: typeof fetch
@@ -85,8 +119,9 @@ export function createFetchTransport(opts: FetchTransportOptions): Transport {
85
119
  }, req.timeoutMs)
86
120
  : undefined
87
121
  signal?.addEventListener('abort', () => ctl.abort())
122
+ const url = buildUrl(opts.baseUrl(req.service), req)
88
123
  try {
89
- const res = await f(buildUrl(opts.baseUrl(req.service), req), {
124
+ const res = await f(url, {
90
125
  method: req.method,
91
126
  headers,
92
127
  body,
@@ -112,7 +147,7 @@ export function createFetchTransport(opts: FetchTransportOptions): Transport {
112
147
  if (timedOut) throw new Error(`Timed out after ${req.timeoutMs}ms: no answer from the API.`)
113
148
  // a person pressing Stop, or the run's own deadline: say so, not "This operation was aborted"
114
149
  if (signal?.aborted) throw new Error('Stopped before the API answered.')
115
- throw e
150
+ throw unreachable(e, url)
116
151
  } finally {
117
152
  if (timer) clearTimeout(timer)
118
153
  }
package/src/template.ts CHANGED
@@ -2,8 +2,13 @@
2
2
 
3
3
  import { faker } from './faker'
4
4
 
5
- const WHOLE = /^\s*\{\{\s*([\w.$-]+)\s*\}\}\s*$/
6
- const EMBED = /\{\{\s*([\w.$-]+)\s*\}\}/g
5
+ // a name is words joined by dots, and a position in an array can be a dot (`tags.0.name`) or brackets (`tags[0].name`)
6
+ const NAME = String.raw`[\w.$\[\]-]+`
7
+ const WHOLE = new RegExp(String.raw`^\s*\{\{\s*(${NAME})\s*\}\}\s*$`)
8
+ const EMBED = new RegExp(String.raw`\{\{\s*(${NAME})\s*\}\}`, 'g')
9
+
10
+ /** The variable a placeholder starts from: `tags` for `tags[0].name`, `pet` for `pet.category.name`. */
11
+ export const rootName = (name: string): string => name.split(/[.[]/, 1)[0] as string
7
12
 
8
13
  /** Namespaces resolved at the transport boundary, never in the request model. */
9
14
  export const DEFERRED_NAMESPACES = ['secrets', 'session'] as const
@@ -39,11 +44,15 @@ export function usesFaker(value: unknown): boolean {
39
44
  return [...collectPlaceholders(value)].some(isFakerName)
40
45
  }
41
46
 
47
+ /** `tags[0].name` is `tags.0.name`: a position in brackets is the same step as one after a dot. */
48
+ const steps = (path: string) => path.replace(/\[(\d+)\]/g, '.$1').split('.')
49
+
42
50
  function lookup(scope: Record<string, unknown>, path: string): { found: boolean; value?: unknown } {
43
51
  if (isFakerName(path)) return fromFaker(path)
44
52
  let cur: unknown = scope
45
- for (const part of path.split('.')) {
46
- if (cur !== null && typeof cur === 'object' && part in (cur as object)) {
53
+ for (const part of steps(path)) {
54
+ // a value's own properties only: `toString` and `constructor` are not data, so a placeholder cannot reach them
55
+ if (cur !== null && typeof cur === 'object' && Object.hasOwn(cur as object, part)) {
47
56
  cur = (cur as Record<string, unknown>)[part]
48
57
  } else {
49
58
  return { found: false }