@open-mercato/shared 0.9.1-develop.7477.1.64ba865101 → 0.9.1-develop.7497.1.543ea44c40

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": "@open-mercato/shared",
3
- "version": "0.9.1-develop.7477.1.64ba865101",
3
+ "version": "0.9.1-develop.7497.1.543ea44c40",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -121,7 +121,7 @@
121
121
  "@mikro-orm/core": "^7.1.14",
122
122
  "@mikro-orm/decorators": "^7.1.14",
123
123
  "@mikro-orm/postgresql": "^7.1.14",
124
- "@open-mercato/cache": "0.9.1-develop.7477.1.64ba865101",
124
+ "@open-mercato/cache": "0.9.1-develop.7497.1.543ea44c40",
125
125
  "@types/html-to-text": "^9.0.4",
126
126
  "@types/sanitize-html": "^2.16.1",
127
127
  "dotenv": "^17.4.2",
@@ -91,9 +91,9 @@ function writeCompiledPair(
91
91
  fs.writeFileSync(compiledPath, source.compiled)
92
92
  const sourceRelativePath = path.relative(appRoot, sourcePath).split(path.sep).join('/')
93
93
  fs.writeFileSync(`${compiledPath}.cache.json`, JSON.stringify({
94
- version: 4,
94
+ version: 5,
95
95
  inputHash: hash(JSON.stringify({
96
- version: 4,
96
+ version: 5,
97
97
  sourceHash: hash(source.ts),
98
98
  tsconfigHashes: {
99
99
  'tsconfig.json': hash(APP_TSCONFIG),
@@ -132,9 +132,9 @@ function writeCompiledPair(
132
132
  fs.writeFileSync(compiledPath, source.compiled)
133
133
  const sourceRelativePath = path.relative(appRoot, sourcePath).split(path.sep).join('/')
134
134
  fs.writeFileSync(`${compiledPath}.cache.json`, JSON.stringify({
135
- version: 4,
135
+ version: 5,
136
136
  inputHash: hash(JSON.stringify({
137
- version: 4,
137
+ version: 5,
138
138
  sourceHash: hash(source.ts),
139
139
  tsconfigHashes: {
140
140
  'tsconfig.json': hash(APP_TSCONFIG),
@@ -77,7 +77,7 @@ function writeGeneratedModule(generatedDir: string, baseName: string, source: {
77
77
  function writeCompiledSibling(generatedDir: string, baseName: string, compiled: string) {
78
78
  const source = fs.readFileSync(path.join(generatedDir, `${baseName}.ts`), 'utf8')
79
79
  const inputHash = hash(JSON.stringify({
80
- version: 4,
80
+ version: 5,
81
81
  sourceHash: hash(source),
82
82
  tsconfigHashes: {
83
83
  'tsconfig.json': hash(APP_TSCONFIG),
@@ -90,7 +90,7 @@ function writeCompiledSibling(generatedDir: string, baseName: string, compiled:
90
90
  const compiledPath = path.join(generatedDir, `${baseName}.mjs`)
91
91
  fs.writeFileSync(compiledPath, compiled)
92
92
  fs.writeFileSync(`${compiledPath}.cache.json`, JSON.stringify({
93
- version: 4,
93
+ version: 5,
94
94
  inputHash,
95
95
  outputHash: hash(compiled),
96
96
  dependencies: {
@@ -77,9 +77,9 @@ function writeGeneratedModule(generatedDir: string, baseName: string, source: {
77
77
  fs.writeFileSync(sourcePath, source.ts)
78
78
  fs.writeFileSync(compiledPath, source.compiled)
79
79
  fs.writeFileSync(`${compiledPath}.cache.json`, JSON.stringify({
80
- version: 4,
80
+ version: 5,
81
81
  inputHash: hash(JSON.stringify({
82
- version: 4,
82
+ version: 5,
83
83
  sourceHash: hash(source.ts),
84
84
  tsconfigHashes: {
85
85
  'tsconfig.json': hash(APP_TSCONFIG),
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @jest-environment node
3
+ *
4
+ * The CLI bundle externalizes every bare package import. Node's ESM resolver only maps an
5
+ * extensionless subpath such as `next/link` when the package publishes an `exports` map; for a
6
+ * legacy package (no `exports`, like `next`) it requires the file extension and the bundle
7
+ * fails with ERR_MODULE_NOT_FOUND (#6993). The external plugin therefore rewrites such
8
+ * subpaths to the file `require.resolve` finds, and leaves everything else untouched.
9
+ */
10
+ import { spawnSync } from 'node:child_process'
11
+ import fs from 'node:fs'
12
+ import os from 'node:os'
13
+ import path from 'node:path'
14
+
15
+ import { compileAppSourceFile, createCliBundlePlugins } from '../dynamicLoader'
16
+
17
+ function writePackage(appRoot: string, name: string, manifest: Record<string, unknown>, files: Record<string, string>) {
18
+ const packageDir = path.join(appRoot, 'node_modules', ...name.split('/'))
19
+ fs.mkdirSync(packageDir, { recursive: true })
20
+ fs.writeFileSync(path.join(packageDir, 'package.json'), JSON.stringify({ name, version: '1.0.0', ...manifest }))
21
+ for (const [relativePath, content] of Object.entries(files)) {
22
+ const filePath = path.join(packageDir, relativePath)
23
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
24
+ fs.writeFileSync(filePath, content)
25
+ }
26
+ }
27
+
28
+ async function bundleWithCliPlugins(appRoot: string, source: string): Promise<{ outfile: string; output: string }> {
29
+ const esbuild = await import('esbuild')
30
+ const generatedDir = path.join(appRoot, '.mercato', 'generated')
31
+ fs.mkdirSync(generatedDir, { recursive: true })
32
+ const entry = path.join(generatedDir, 'entry.ts')
33
+ fs.writeFileSync(entry, source)
34
+ const outfile = path.join(generatedDir, 'entry.mjs')
35
+ await esbuild.build({
36
+ entryPoints: [entry],
37
+ outfile,
38
+ absWorkingDir: appRoot,
39
+ bundle: true,
40
+ format: 'esm',
41
+ platform: 'node',
42
+ target: 'node18',
43
+ plugins: createCliBundlePlugins(appRoot),
44
+ })
45
+ return { outfile, output: fs.readFileSync(outfile, 'utf8') }
46
+ }
47
+
48
+ describe('createCliBundlePlugins — externalized package subpaths', () => {
49
+ let appRoot: string
50
+
51
+ beforeEach(() => {
52
+ appRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'om-external-subpath-')))
53
+ fs.writeFileSync(path.join(appRoot, 'package.json'), JSON.stringify({ name: 'app', type: 'module' }))
54
+ writePackage(appRoot, 'legacy-pkg', { main: 'index.js' }, {
55
+ 'index.js': "module.exports = 'legacy-root'\n",
56
+ 'link.js': "module.exports = 'legacy-link'\n",
57
+ 'dist/nested/index.js': "module.exports = 'legacy-nested'\n",
58
+ })
59
+ writePackage(appRoot, '@scope/legacy-scoped', {}, {
60
+ 'tools.js': "module.exports = 'scoped-tools'\n",
61
+ })
62
+ writePackage(appRoot, 'exports-pkg', { exports: { './feature': './lib/feature.js' } }, {
63
+ 'lib/feature.js': "module.exports = 'exports-feature'\n",
64
+ })
65
+ })
66
+
67
+ afterEach(() => {
68
+ fs.rmSync(appRoot, { recursive: true, force: true })
69
+ })
70
+
71
+ it('appends the resolved file to extensionless subpaths of packages without an exports map', async () => {
72
+ const { output } = await bundleWithCliPlugins(appRoot, [
73
+ "import link from 'legacy-pkg/link'",
74
+ "import nested from 'legacy-pkg/dist/nested'",
75
+ "import tools from '@scope/legacy-scoped/tools'",
76
+ 'export const values = [link, nested, tools]',
77
+ '',
78
+ ].join('\n'))
79
+
80
+ expect(output).toContain('"legacy-pkg/link.js"')
81
+ expect(output).toContain('"legacy-pkg/dist/nested/index.js"')
82
+ expect(output).toContain('"@scope/legacy-scoped/tools.js"')
83
+ })
84
+
85
+ it('keeps package roots, exports-mapped subpaths, builtins and unresolvable specifiers unchanged', async () => {
86
+ const { output } = await bundleWithCliPlugins(appRoot, [
87
+ "import root from 'legacy-pkg'",
88
+ "import feature from 'exports-pkg/feature'",
89
+ "import explicit from 'legacy-pkg/link.js'",
90
+ "import { readFile } from 'fs/promises'",
91
+ "import missing from 'missing-pkg/sub'",
92
+ 'export const values = [root, feature, explicit, readFile, missing]',
93
+ '',
94
+ ].join('\n'))
95
+
96
+ expect(output).toContain('"legacy-pkg"')
97
+ expect(output).toContain('"exports-pkg/feature"')
98
+ expect(output).toContain('"legacy-pkg/link.js"')
99
+ expect(output).toContain('"fs/promises"')
100
+ expect(output).toContain('"missing-pkg/sub"')
101
+ })
102
+
103
+ it('produces a bundle Node ESM can load', async () => {
104
+ const { outfile } = await bundleWithCliPlugins(appRoot, [
105
+ "import link from 'legacy-pkg/link'",
106
+ "import feature from 'exports-pkg/feature'",
107
+ 'console.log(JSON.stringify([link, feature]))',
108
+ '',
109
+ ].join('\n'))
110
+
111
+ const result = spawnSync(process.execPath, [outfile], { encoding: 'utf8' })
112
+
113
+ expect(result.stderr).not.toContain('ERR_MODULE_NOT_FOUND')
114
+ expect(result.status).toBe(0)
115
+ expect(JSON.parse(result.stdout.trim())).toEqual(['legacy-link', 'exports-feature'])
116
+ })
117
+
118
+ it('records the rewritten package manifest so a package upgrade invalidates the cached bundle', async () => {
119
+ fs.writeFileSync(path.join(appRoot, 'tsconfig.json'), JSON.stringify({ compilerOptions: { target: 'ES2022' } }))
120
+ const generatedDir = path.join(appRoot, '.mercato', 'generated')
121
+ fs.mkdirSync(generatedDir, { recursive: true })
122
+ const entry = path.join(generatedDir, 'registry.generated.ts')
123
+ fs.writeFileSync(entry, "import link from 'legacy-pkg/link'\nexport const value = link\n")
124
+ const outFile = path.join(generatedDir, 'registry.generated.mjs')
125
+
126
+ await compileAppSourceFile(entry, { appRoot, outFile })
127
+ const metadata = JSON.parse(fs.readFileSync(`${outFile}.cache.json`, 'utf8')) as {
128
+ dependencies: Record<string, string>
129
+ }
130
+ expect(Object.keys(metadata.dependencies)).toContain('node_modules/legacy-pkg/package.json')
131
+
132
+ const firstOutputMtime = fs.statSync(outFile).mtimeMs
133
+ await compileAppSourceFile(entry, { appRoot, outFile })
134
+ expect(fs.statSync(outFile).mtimeMs).toBe(firstOutputMtime)
135
+
136
+ writePackage(appRoot, 'legacy-pkg', { exports: { './link': './link.js' } }, {})
137
+ await compileAppSourceFile(entry, { appRoot, outFile })
138
+ expect(fs.readFileSync(outFile, 'utf8')).toContain('"legacy-pkg/link"')
139
+ expect(fs.readFileSync(outFile, 'utf8')).not.toContain('"legacy-pkg/link.js"')
140
+ })
141
+ })
@@ -133,7 +133,7 @@ describe('dynamic loader app tsconfig and content-addressed cache', () => {
133
133
  loadBootstrapDataInNode(appRoot)
134
134
  const after = readCacheMetadata(appRoot, 'entities')
135
135
 
136
- expect(after.version).toBe(4)
136
+ expect(after.version).toBe(5)
137
137
  expect(after.inputHash).not.toBe(before.inputHash)
138
138
  })
139
139
 
@@ -287,6 +287,6 @@ describe('dynamic loader app tsconfig and content-addressed cache', () => {
287
287
 
288
288
  loadBootstrapDataInNode(appRoot)
289
289
 
290
- expect(readCacheMetadata(appRoot, 'entities').version).toBe(4)
290
+ expect(readCacheMetadata(appRoot, 'entities').version).toBe(5)
291
291
  })
292
292
  })
@@ -16,7 +16,7 @@ import { CLIENT_ONLY_STUB_NAMESPACE, createClientOnlyStubPlugin } from './client
16
16
  import path from 'node:path'
17
17
  import fs from 'node:fs'
18
18
  import crypto from 'node:crypto'
19
- import { createRequire } from 'node:module'
19
+ import { createRequire, isBuiltin } from 'node:module'
20
20
  import { pathToFileURL } from 'node:url'
21
21
 
22
22
  let activeBootstrapLoads = 0
@@ -77,6 +77,44 @@ class GeneratedFileNotFoundError extends Error {
77
77
  }
78
78
  }
79
79
 
80
+ function splitBareSpecifier(specifier: string): { packageName: string; subpath: string } | null {
81
+ const segments = specifier.split('/')
82
+ const nameLength = specifier.startsWith('@') ? 2 : 1
83
+ if (segments.length <= nameLength || segments.slice(0, nameLength).some((segment) => !segment)) return null
84
+ return {
85
+ packageName: segments.slice(0, nameLength).join('/'),
86
+ subpath: segments.slice(nameLength).join('/'),
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Rewrites an externalized `pkg/subpath` specifier to the file Node's ESM resolver can load
92
+ * when `pkg` publishes no `exports` map (#6993). Builtins, packages with `exports`, subpaths
93
+ * that already carry an extension and anything that fails to resolve keep the original
94
+ * specifier, so the bundle never gets worse than before.
95
+ */
96
+ function rewriteLegacySubpathSpecifier(
97
+ specifier: string,
98
+ appRequire: NodeRequire,
99
+ onRewrite: (manifestPath: string) => void,
100
+ ): string {
101
+ if (isBuiltin(specifier)) return specifier
102
+ const parts = splitBareSpecifier(specifier)
103
+ if (!parts || path.posix.extname(parts.subpath)) return specifier
104
+ try {
105
+ const manifestPath = appRequire.resolve(`${parts.packageName}/package.json`)
106
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { exports?: unknown }
107
+ if (manifest.exports != null) return specifier
108
+ const resolvedFile = appRequire.resolve(specifier)
109
+ const relativeFile = path.relative(path.dirname(manifestPath), resolvedFile)
110
+ if (!relativeFile || relativeFile.startsWith('..') || path.isAbsolute(relativeFile)) return specifier
111
+ onRewrite(manifestPath)
112
+ return `${parts.packageName}/${relativeFile.split(path.sep).join('/')}`
113
+ } catch {
114
+ return specifier
115
+ }
116
+ }
117
+
80
118
  /**
81
119
  * esbuild plugins for the CLI bundle, in resolution order. The client-only stub must come
82
120
  * first so it wins over the alias and external plugins for `*.client` dynamic imports.
@@ -85,7 +123,10 @@ class GeneratedFileNotFoundError extends Error {
85
123
  * `createClientOnlyStubPlugin` in isolation stays green if the plugin is dropped from this
86
124
  * list, which would silently reintroduce #4623.
87
125
  */
88
- export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugin[] {
126
+ export function createCliBundlePlugins(
127
+ appRoot: string,
128
+ options: { onLegacySubpathRewrite?: (manifestPath: string) => void } = {},
129
+ ): import('esbuild').Plugin[] {
89
130
  // Plugin to resolve the @/ alias the way the app tsconfig maps it:
90
131
  // `@/.mercato/*` to the app root, every other `@/*` to the app's src/ directory.
91
132
  const aliasPlugin: import('esbuild').Plugin = {
@@ -113,6 +154,19 @@ export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugi
113
154
  },
114
155
  }
115
156
 
157
+ // Node's ESM resolver only maps an extensionless package subpath (`next/link`) when the
158
+ // package publishes an `exports` map; legacy packages need the real file path instead.
159
+ const appRequire = createRequire(path.join(appRoot, 'package.json'))
160
+ const externalSpecifierCache = new Map<string, string>()
161
+ const onLegacySubpathRewrite = options.onLegacySubpathRewrite ?? (() => undefined)
162
+ const resolveExternalSpecifier = (specifier: string): string => {
163
+ const cached = externalSpecifierCache.get(specifier)
164
+ if (cached !== undefined) return cached
165
+ const resolved = rewriteLegacySubpathSpecifier(specifier, appRequire, onLegacySubpathRewrite)
166
+ externalSpecifierCache.set(specifier, resolved)
167
+ return resolved
168
+ }
169
+
116
170
  // Plugin to mark non-JSON package imports as external
117
171
  const externalNonJsonPlugin: import('esbuild').Plugin = {
118
172
  name: 'external-non-json',
@@ -129,7 +183,7 @@ export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugi
129
183
  return null // Let esbuild handle it
130
184
  }
131
185
  // Otherwise mark as external
132
- return { path: args.path, external: true }
186
+ return { path: resolveExternalSpecifier(args.path), external: true }
133
187
  })
134
188
  },
135
189
  }
@@ -137,7 +191,7 @@ export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugi
137
191
  return [createClientOnlyStubPlugin(), aliasPlugin, externalNonJsonPlugin]
138
192
  }
139
193
 
140
- const DYNAMIC_LOADER_CACHE_VERSION = 4
194
+ const DYNAMIC_LOADER_CACHE_VERSION = 5
141
195
 
142
196
  type DynamicLoaderCacheMetadata = {
143
197
  version: number
@@ -433,6 +487,7 @@ async function compileAppSourceFileWithActiveEsbuild(
433
487
  // Dynamically import esbuild only when needed
434
488
  const esbuild = await getEsbuildRuntime()
435
489
 
490
+ const rewrittenPackageManifests = new Set<string>()
436
491
  // Use esbuild.build with bundling to handle JSON imports
437
492
  const result = await esbuild.build({
438
493
  entryPoints: [tsPath],
@@ -444,7 +499,9 @@ async function compileAppSourceFileWithActiveEsbuild(
444
499
  platform: 'node',
445
500
  target: 'node18',
446
501
  tsconfig: appTsconfig,
447
- plugins: createCliBundlePlugins(appRoot),
502
+ plugins: createCliBundlePlugins(appRoot, {
503
+ onLegacySubpathRewrite: (manifestPath) => rewrittenPackageManifests.add(manifestPath),
504
+ }),
448
505
  // Allow JSON imports
449
506
  loader: { '.json': 'json' },
450
507
  })
@@ -455,6 +512,7 @@ async function compileAppSourceFileWithActiveEsbuild(
455
512
  dependencies: {
456
513
  ...collectDependencyHashes(appRoot, result.metafile.inputs),
457
514
  ...hashFilesRelativeTo(appRoot, tsconfigPaths),
515
+ ...hashFilesRelativeTo(appRoot, [...rewrittenPackageManifests]),
458
516
  },
459
517
  }
460
518
  fs.writeFileSync(metadataPath, JSON.stringify(metadata))
@@ -1,4 +1,11 @@
1
- import { MAX_IDS_PER_REQUEST, mergeIdFilter, parseIdsParam, isIdsParamProvided } from '@open-mercato/shared/lib/crud/ids'
1
+ import {
2
+ MAX_IDS_PER_REQUEST,
3
+ mergeIdFilter,
4
+ parseIdsParam,
5
+ isIdsParamProvided,
6
+ isUuidListParam,
7
+ splitIdListParam,
8
+ } from '@open-mercato/shared/lib/crud/ids'
2
9
 
3
10
  describe('crud ids helpers', () => {
4
11
  const idA = '550e8400-e29b-41d4-a716-446655440001'
@@ -168,4 +175,30 @@ describe('crud ids helpers', () => {
168
175
  tenantId: 't1',
169
176
  })
170
177
  })
178
+
179
+ it('splitIdListParam trims entries and drops empty ones', () => {
180
+ expect(splitIdListParam(` ${idA} ,, ${idB} ,`)).toEqual([idA, idB])
181
+ expect(splitIdListParam(undefined)).toEqual([])
182
+ expect(splitIdListParam(['not', 'a', 'string'])).toEqual([])
183
+ })
184
+
185
+ it('isUuidListParam accepts absent, empty, and all-UUID lists', () => {
186
+ expect(isUuidListParam(undefined)).toBe(true)
187
+ expect(isUuidListParam('')).toBe(true)
188
+ expect(isUuidListParam(` ${idA}, ${idB.toUpperCase()} `)).toBe(true)
189
+ expect(isUuidListParam('00000000-0000-0000-0000-000000000000')).toBe(true)
190
+ })
191
+
192
+ it('isUuidListParam rejects malformed and mixed lists', () => {
193
+ expect(isUuidListParam('not-a-uuid')).toBe(false)
194
+ expect(isUuidListParam(`${idA},not-a-uuid`)).toBe(false)
195
+ expect(isUuidListParam(`${idA}0`)).toBe(false)
196
+ expect(isUuidListParam(`{${idA}}`)).toBe(false)
197
+ })
198
+
199
+ it('isUuidListParam fails closed on non-string values', () => {
200
+ expect(isUuidListParam(['not-a-uuid'])).toBe(false)
201
+ expect(isUuidListParam([idA])).toBe(false)
202
+ expect(isUuidListParam(null)).toBe(false)
203
+ })
171
204
  })
@@ -51,6 +51,30 @@ export function parseIdsParam(raw: unknown, maxIds: number = MAX_IDS_PER_REQUEST
51
51
  return parsed.slice(0, safeMax)
52
52
  }
53
53
 
54
+ const UUID_SHAPE_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
55
+
56
+ /**
57
+ * Splits a comma-separated id list query param into its trimmed, non-empty entries.
58
+ */
59
+ export function splitIdListParam(raw: unknown): string[] {
60
+ if (typeof raw !== 'string') return []
61
+ return raw
62
+ .split(',')
63
+ .map((value) => value.trim())
64
+ .filter((value) => value.length > 0)
65
+ }
66
+
67
+ /**
68
+ * Whether every entry of a comma-separated id list query param is UUID-shaped, so it can be
69
+ * bound to a uuid column without a database error. Use it to reject a malformed or mixed list
70
+ * with a 400 instead of dropping the bad entries and widening the result set (#7265).
71
+ */
72
+ export function isUuidListParam(raw: unknown): boolean {
73
+ if (raw === undefined) return true
74
+ if (typeof raw !== 'string') return false
75
+ return splitIdListParam(raw).every((value) => UUID_SHAPE_REGEX.test(value))
76
+ }
77
+
54
78
  /**
55
79
  * Whether an `?ids=` param was supplied at all (a non-empty string, or repeated
56
80
  * `?ids=` occurrences), regardless of whether any value survived UUID
@@ -0,0 +1,102 @@
1
+ import { z } from 'zod'
2
+ import { buildOpenApiDocument } from '../generator'
3
+ import type { Module } from '../../../modules/registry'
4
+
5
+ type ParameterNode = { name?: string; in?: string; required?: boolean }
6
+ type OperationNode = { operationId?: string; parameters?: ParameterNode[] }
7
+
8
+ const noopHandler = async () => new Response(null)
9
+
10
+ function imageModule(extraApis: unknown[] = [], operationId?: string): Module {
11
+ return {
12
+ id: 'attachments',
13
+ apis: [
14
+ {
15
+ path: '/attachments/image/[id]/[[...slug]]',
16
+ handlers: { GET: noopHandler },
17
+ docs: {
18
+ methods: {
19
+ GET: {
20
+ summary: 'Serve image',
21
+ ...(operationId ? { operationId } : {}),
22
+ pathParams: z.object({ id: z.string().uuid() }),
23
+ responses: [{ status: 200, description: 'Image' }],
24
+ },
25
+ },
26
+ },
27
+ },
28
+ ...extraApis,
29
+ ],
30
+ } as unknown as Module
31
+ }
32
+
33
+ function operation(doc: ReturnType<typeof buildOpenApiDocument>, path: string): OperationNode | undefined {
34
+ return (doc.paths[path] as Record<string, OperationNode> | undefined)?.get
35
+ }
36
+
37
+ function collectPathParameterViolations(doc: ReturnType<typeof buildOpenApiDocument>): string[] {
38
+ const violations: string[] = []
39
+ const operationIds = new Set<string>()
40
+ for (const [path, item] of Object.entries(doc.paths)) {
41
+ const templateNames = Array.from(path.matchAll(/\{([^}]+)\}/g), (match) => match[1])
42
+ for (const [method, op] of Object.entries(item as Record<string, OperationNode>)) {
43
+ const pathParams = (op.parameters ?? []).filter((param) => param.in === 'path')
44
+ for (const param of pathParams) {
45
+ if (param.required !== true) violations.push(`${method} ${path}: path parameter ${param.name} is not required`)
46
+ if (!templateNames.includes(param.name ?? '')) violations.push(`${method} ${path}: path parameter ${param.name} is not in the template`)
47
+ }
48
+ for (const name of templateNames) {
49
+ if (!pathParams.some((param) => param.name === name)) violations.push(`${method} ${path}: template variable ${name} is not declared`)
50
+ }
51
+ if (op.operationId) {
52
+ if (operationIds.has(op.operationId)) violations.push(`${method} ${path}: duplicate operationId ${op.operationId}`)
53
+ operationIds.add(op.operationId)
54
+ }
55
+ }
56
+ }
57
+ return violations
58
+ }
59
+
60
+ describe('buildOpenApiDocument optional catch-all path parameters', () => {
61
+ it('emits id-only and id-plus-slug paths with only required path parameters', () => {
62
+ const doc = buildOpenApiDocument([imageModule()])
63
+
64
+ expect(collectPathParameterViolations(doc)).toEqual([])
65
+
66
+ const withSlug = operation(doc, '/attachments/image/{id}/{slug}')
67
+ const withoutSlug = operation(doc, '/attachments/image/{id}')
68
+ expect(withSlug?.parameters?.filter((param) => param.in === 'path').map((param) => param.name)).toEqual(['id', 'slug'])
69
+ expect(withoutSlug?.parameters?.filter((param) => param.in === 'path').map((param) => param.name)).toEqual(['id'])
70
+ })
71
+
72
+ it('keeps the published operationId on the slug path and gives the id-only path a distinct one', () => {
73
+ const doc = buildOpenApiDocument([imageModule()])
74
+
75
+ expect(operation(doc, '/attachments/image/{id}/{slug}')?.operationId).toBe('attachments_get_attachments_image_id_slug')
76
+ expect(operation(doc, '/attachments/image/{id}')?.operationId).toBe('attachments_get_attachments_image_id')
77
+ })
78
+
79
+ it('derives a distinct id-only operationId from an explicit operationId', () => {
80
+ const doc = buildOpenApiDocument([imageModule([], 'serveImage')])
81
+
82
+ expect(operation(doc, '/attachments/image/{id}/{slug}')?.operationId).toBe('serveImage')
83
+ expect(operation(doc, '/attachments/image/{id}')?.operationId).toBe('serveImage_without_slug')
84
+ })
85
+
86
+ it('never replaces a concrete route that owns the id-only path, whichever is registered first', () => {
87
+ const concreteRoute = {
88
+ path: '/attachments/image/[id]',
89
+ handlers: { GET: noopHandler },
90
+ docs: { methods: { GET: { summary: 'Concrete', operationId: 'concreteImage', responses: [{ status: 200, description: 'OK' }] } } },
91
+ }
92
+ const after = buildOpenApiDocument([imageModule([concreteRoute])])
93
+ const before = buildOpenApiDocument([
94
+ { id: 'other', apis: [concreteRoute] } as unknown as Module,
95
+ imageModule(),
96
+ ])
97
+
98
+ expect(operation(after, '/attachments/image/{id}')?.operationId).toBe('concreteImage')
99
+ expect(operation(before, '/attachments/image/{id}')?.operationId).toBe('concreteImage')
100
+ expect(collectPathParameterViolations(after)).toEqual([])
101
+ })
102
+ })