mikoshi-construct 0.1.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/LICENSE +21 -0
- package/README.md +161 -0
- package/dist/cli.js +1492 -0
- package/package.json +73 -0
- package/templates/ai/claude/CLAUDE.md.eta +18 -0
- package/templates/ai/claude/CLAUDE.md.existing.eta +15 -0
- package/templates/ai/claude/_claude/agents/architect.md +38 -0
- package/templates/ai/claude/_claude/agents/harness.md +40 -0
- package/templates/ai/claude/_claude/agents/implementer.md +40 -0
- package/templates/ai/claude/_claude/commands/plan.md +24 -0
- package/templates/ai/claude/_claude/skills/implement/SKILL.md +42 -0
- package/templates/ai/claude/scripts/construct/implement.workflow.mjs +185 -0
- package/templates/ai/cursor/_cursor/rules/construct.mdc +19 -0
- package/templates/ai/review/_github/workflows/claude-review.yml.eta +51 -0
- package/templates/ai/shared/AGENTS.md.eta +123 -0
- package/templates/ai/shared/AGENTS.md.existing.eta +50 -0
- package/templates/ai/shared/_claude/commands/construct-discover.md +75 -0
- package/templates/ai/shared/_claude/rules/conventions.md +63 -0
- package/templates/ai/shared/_claude/rules/secrets.md +8 -0
- package/templates/ai/shared/_claude/rules/tests.md +6 -0
- package/templates/base/_editorconfig +12 -0
- package/templates/base/_github/workflows/security.yml +41 -0
- package/templates/base/_gitignore +11 -0
- package/templates/base/_gitleaks.toml +2 -0
- package/templates/base/_nvmrc.eta +1 -0
- package/templates/base/_vscode/settings.json +22 -0
- package/templates/base/architecture/checklists.md +42 -0
- package/templates/base/architecture/principles.md +113 -0
- package/templates/base/architecture/security-invariants.md +17 -0
- package/templates/harness/_github/workflows/ci.yml.eta +31 -0
- package/templates/harness/eslint.config.mjs +13 -0
- package/templates/harness/package.json.eta +29 -0
- package/templates/harness/pnpm-workspace.yaml +11 -0
- package/templates/harness/scripts/composition/check.ts +19 -0
- package/templates/harness/scripts/composition/files.ts +34 -0
- package/templates/harness/scripts/composition/model.ts +137 -0
- package/templates/harness/scripts/composition/render.ts +72 -0
- package/templates/harness/scripts/composition/sync-docs.ts +11 -0
- package/templates/harness/scripts/tests/composition/files.test.ts +16 -0
- package/templates/harness/scripts/tests/composition/model.test.ts +41 -0
- package/templates/harness/scripts/tests/composition/render.test.ts +54 -0
- package/templates/harness/tsconfig.base.json +14 -0
- package/templates/harness/tsconfig.json +5 -0
- package/templates/harness/vitest.config.ts +8 -0
- package/templates/presets/monorepo/baseline/eslint.config.mjs.eta +100 -0
- package/templates/presets/monorepo/baseline/package.json.eta +18 -0
- package/templates/presets/monorepo/baseline/packages/shared/package.json.eta +22 -0
- package/templates/presets/monorepo/baseline/packages/shared/src/api/openapi.ts +81 -0
- package/templates/presets/monorepo/baseline/packages/shared/src/index.ts +1 -0
- package/templates/presets/monorepo/baseline/packages/shared/tsconfig.build.json +10 -0
- package/templates/presets/monorepo/baseline/packages/shared/tsconfig.json +4 -0
- package/templates/presets/monorepo/baseline/pnpm-workspace.yaml.eta +31 -0
- package/templates/presets/monorepo/baseline/tsconfig.json +12 -0
- package/templates/presets/monorepo/baseline/vitest.config.ts +8 -0
- package/templates/presets/monorepo/sample/apps/api/package.json.eta +23 -0
- package/templates/presets/monorepo/sample/apps/api/tsconfig.build.json +10 -0
- package/templates/presets/monorepo/sample/apps/api/tsconfig.json +4 -0
- package/templates/presets/monorepo/sample/scripts/tests/lint/syntax-policy.test.ts.eta +36 -0
- package/templates/presets/node-backend/baseline/eslint.config.mjs +59 -0
- package/templates/presets/node-backend/baseline/package.json.eta +20 -0
- package/templates/presets/node-backend/baseline/src/contracts/openapi.ts +81 -0
- package/templates/presets/node-backend/baseline/tsconfig.build.json +10 -0
- package/templates/presets/node-backend/sample/scripts/tests/lint/syntax-policy.test.ts +31 -0
- package/templates/presets/node-frontend/baseline/_claude/rules/css.md +158 -0
- package/templates/presets/node-frontend/baseline/eslint.config.mjs +44 -0
- package/templates/presets/node-frontend/baseline/package.json.eta +15 -0
- package/templates/presets/node-frontend/baseline/tsconfig.json +11 -0
- package/templates/presets/node-frontend/baseline/vitest.config.ts +10 -0
- package/templates/presets/node-frontend/sample/architecture/app.md +37 -0
- package/templates/presets/node-frontend/sample/architecture/composition/app.yaml +41 -0
- package/templates/presets/node-frontend/sample/index.html.eta +12 -0
- package/templates/presets/node-frontend/sample/src/app.ts +32 -0
- package/templates/presets/node-frontend/sample/src/main.ts +9 -0
- package/templates/presets/node-frontend/sample/src/styles/app.css +43 -0
- package/templates/presets/node-frontend/sample/src/styles/tokens.css +16 -0
- package/templates/presets/node-frontend/sample/tests/app.test.ts +21 -0
- package/templates/stacks/express-api/app/src/app.ts +27 -0
- package/templates/stacks/express-api/app/src/config.ts.eta +30 -0
- package/templates/stacks/express-api/app/src/contracts/types.ts.eta +1 -0
- package/templates/stacks/express-api/app/src/health/health.controller.ts +12 -0
- package/templates/stacks/express-api/app/src/health/health.service.ts +23 -0
- package/templates/stacks/express-api/app/src/http/api-contract.middleware.ts +13 -0
- package/templates/stacks/express-api/app/src/http/app-error.ts +14 -0
- package/templates/stacks/express-api/app/src/http/async-handler.middleware.ts +7 -0
- package/templates/stacks/express-api/app/src/http/error-handler.middleware.ts +50 -0
- package/templates/stacks/express-api/app/src/server.ts +9 -0
- package/templates/stacks/express-api/app/tests/health.test.ts +28 -0
- package/templates/stacks/express-api/app/tests/http/error-handler.middleware.test.ts +48 -0
- package/templates/stacks/express-api/repo/architecture/composition/http.yaml.eta +45 -0
- package/templates/stacks/express-api/repo/architecture/http.md +39 -0
- package/templates/stacks/http-contract/_github/workflows/api-contract.yml +36 -0
- package/templates/stacks/http-contract/contracts/api/openapi.yaml.eta +62 -0
- package/templates/stacks/http-contract/package.json.eta +13 -0
- package/templates/stacks/http-contract/redocly.yaml.eta +8 -0
- package/templates/stacks/http-contract/scripts/contracts/types.mjs.eta +20 -0
- package/templates/stacks/http-contract/scripts/tests/contracts/security.test.ts.eta +86 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "Bundler",
|
|
7
|
+
"types": ["node", "vite/client"]
|
|
8
|
+
},
|
|
9
|
+
"include": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts", "vitest.config.ts"],
|
|
10
|
+
"exclude": ["**/node_modules/**", "**/dist/**"]
|
|
11
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config'
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
projects: [
|
|
6
|
+
{ test: { name: 'app', include: ['tests/**/*.test.ts'], environment: 'happy-dom' } },
|
|
7
|
+
{ test: { name: 'scripts', include: ['scripts/tests/**/*.test.ts'], environment: 'node' } },
|
|
8
|
+
],
|
|
9
|
+
},
|
|
10
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Application
|
|
2
|
+
|
|
3
|
+
The bootstrap path and the wiring below are rendered from
|
|
4
|
+
[composition/app.yaml](composition/app.yaml). Edit the model, then run `pnpm composition:render`;
|
|
5
|
+
`pnpm composition:check` fails when the diagram and the model drift apart.
|
|
6
|
+
|
|
7
|
+
<!-- composition:app -->
|
|
8
|
+
`src/main.ts` is the composition root: it finds the mount point in `index.html`, loads the design tokens and the component styles, and hands the root element to `createApp`. State crosses into CSS as `data-*` attributes; nothing else writes styles from JavaScript.
|
|
9
|
+
|
|
10
|
+
```mermaid
|
|
11
|
+
flowchart LR
|
|
12
|
+
subgraph b_bootstrap["Bootstrap"]
|
|
13
|
+
main["main.ts"]
|
|
14
|
+
end
|
|
15
|
+
subgraph b_ui["UI"]
|
|
16
|
+
app["createApp(root)"]
|
|
17
|
+
counter["createCounter(button)"]
|
|
18
|
+
end
|
|
19
|
+
subgraph b_styles["Styles"]
|
|
20
|
+
tokens["tokens.css"]
|
|
21
|
+
css["app.css"]
|
|
22
|
+
end
|
|
23
|
+
main -.->|"imports"| tokens
|
|
24
|
+
main -.->|"imports"| css
|
|
25
|
+
main --> app
|
|
26
|
+
app --> counter
|
|
27
|
+
counter -.->|"data-tone"| css
|
|
28
|
+
```
|
|
29
|
+
<!-- /composition:app -->
|
|
30
|
+
|
|
31
|
+
## Conventions
|
|
32
|
+
|
|
33
|
+
`src/main.ts` is the only file that touches `document` at module scope; everything else receives its
|
|
34
|
+
root element. State that CSS must see is written as a `data-*` or ARIA attribute (a lint rule rejects
|
|
35
|
+
`classList` mutation and inline style writes in `src/`). Tokens live in
|
|
36
|
+
[src/styles/tokens.css](../src/styles/tokens.css); a raw colour, length or duration in a component is a
|
|
37
|
+
missing token. Layout reacts to its container, never to the viewport.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
id: app
|
|
2
|
+
title: Application composition
|
|
3
|
+
description: >-
|
|
4
|
+
`src/main.ts` is the composition root: it finds the mount point in `index.html`, loads the design
|
|
5
|
+
tokens and the component styles, and hands the root element to `createApp`. State crosses into CSS
|
|
6
|
+
as `data-*` attributes; nothing else writes styles from JavaScript.
|
|
7
|
+
doc: architecture/app.md
|
|
8
|
+
boundaries:
|
|
9
|
+
- id: bootstrap
|
|
10
|
+
label: Bootstrap
|
|
11
|
+
- id: ui
|
|
12
|
+
label: UI
|
|
13
|
+
- id: styles
|
|
14
|
+
label: Styles
|
|
15
|
+
nodes:
|
|
16
|
+
- id: main
|
|
17
|
+
label: main.ts
|
|
18
|
+
path: src/main.ts
|
|
19
|
+
boundary: bootstrap
|
|
20
|
+
- id: app
|
|
21
|
+
label: createApp(root)
|
|
22
|
+
path: src/app.ts
|
|
23
|
+
boundary: ui
|
|
24
|
+
- id: counter
|
|
25
|
+
label: createCounter(button)
|
|
26
|
+
path: src/app.ts
|
|
27
|
+
boundary: ui
|
|
28
|
+
- id: tokens
|
|
29
|
+
label: tokens.css
|
|
30
|
+
path: src/styles/tokens.css
|
|
31
|
+
boundary: styles
|
|
32
|
+
- id: css
|
|
33
|
+
label: app.css
|
|
34
|
+
path: src/styles/app.css
|
|
35
|
+
boundary: styles
|
|
36
|
+
edges:
|
|
37
|
+
- {from: main, to: tokens, kind: wires, label: imports}
|
|
38
|
+
- {from: main, to: css, kind: wires, label: imports}
|
|
39
|
+
- {from: main, to: app, kind: sequence}
|
|
40
|
+
- {from: app, to: counter, kind: sequence}
|
|
41
|
+
- {from: counter, to: css, kind: wires, label: data-tone}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>{{projectName}}</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="app"></div>
|
|
10
|
+
<script type="module" src="/src/main.ts"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface Counter {
|
|
2
|
+
element: HTMLButtonElement
|
|
3
|
+
value: () => number
|
|
4
|
+
increment: () => void
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function render(element: HTMLButtonElement, count: number): void {
|
|
8
|
+
element.dataset.count = String(count)
|
|
9
|
+
element.dataset.tone = count > 0 ? 'active' : 'idle'
|
|
10
|
+
element.textContent = `Clicked ${count} ${count === 1 ? 'time' : 'times'}`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createCounter(element: HTMLButtonElement): Counter {
|
|
14
|
+
let count = 0
|
|
15
|
+
render(element, count)
|
|
16
|
+
element.addEventListener('click', () => {
|
|
17
|
+
count += 1
|
|
18
|
+
render(element, count)
|
|
19
|
+
})
|
|
20
|
+
return { element, value: () => count, increment: () => element.click() }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createApp(root: HTMLElement): Counter {
|
|
24
|
+
const document = root.ownerDocument
|
|
25
|
+
const heading = document.createElement('h1')
|
|
26
|
+
heading.textContent = document.title
|
|
27
|
+
const button = document.createElement('button')
|
|
28
|
+
button.type = 'button'
|
|
29
|
+
button.className = 'counter'
|
|
30
|
+
root.replaceChildren(heading, button)
|
|
31
|
+
return createCounter(button)
|
|
32
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
body {
|
|
2
|
+
margin: 0;
|
|
3
|
+
background: var(--color-surface);
|
|
4
|
+
color: var(--color-on-surface);
|
|
5
|
+
font-family: system-ui, sans-serif;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
#app {
|
|
9
|
+
container: app / inline-size;
|
|
10
|
+
display: grid;
|
|
11
|
+
gap: var(--space-4);
|
|
12
|
+
max-inline-size: var(--body-max-inline-size);
|
|
13
|
+
margin-inline: auto;
|
|
14
|
+
padding-block: var(--space-8);
|
|
15
|
+
padding-inline: var(--space-4);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.counter {
|
|
19
|
+
--_bg: var(--color-surface);
|
|
20
|
+
--_fg: var(--color-on-surface);
|
|
21
|
+
--_bd: var(--color-on-surface);
|
|
22
|
+
|
|
23
|
+
justify-self: start;
|
|
24
|
+
padding: 0.25lh 1ch;
|
|
25
|
+
border: 1px solid var(--_bd);
|
|
26
|
+
border-radius: var(--radius-md);
|
|
27
|
+
background: var(--_bg);
|
|
28
|
+
color: var(--_fg);
|
|
29
|
+
font: inherit;
|
|
30
|
+
transition: background var(--duration-fast) ease;
|
|
31
|
+
|
|
32
|
+
&[data-tone='active'] {
|
|
33
|
+
--_bg: var(--color-primary);
|
|
34
|
+
--_fg: var(--color-on-primary);
|
|
35
|
+
--_bd: transparent;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@container app (inline-size <= 30rem) {
|
|
40
|
+
.counter {
|
|
41
|
+
justify-self: stretch;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light dark;
|
|
3
|
+
|
|
4
|
+
--color-surface: light-dark(oklch(98% 0.01 250), oklch(18% 0.02 250));
|
|
5
|
+
--color-on-surface: light-dark(oklch(22% 0.02 250), oklch(94% 0.01 250));
|
|
6
|
+
--color-primary: light-dark(oklch(52% 0.2 265), oklch(78% 0.14 265));
|
|
7
|
+
--color-on-primary: light-dark(oklch(99% 0 0), oklch(18% 0.05 265));
|
|
8
|
+
|
|
9
|
+
--space-2: 0.5rem;
|
|
10
|
+
--space-4: 1rem;
|
|
11
|
+
--space-8: 2rem;
|
|
12
|
+
--radius-md: 0.5rem;
|
|
13
|
+
--duration-fast: 120ms;
|
|
14
|
+
--body-max-inline-size: 65ch;
|
|
15
|
+
--bp-cq-sm: 30rem;
|
|
16
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { createApp } from '../src/app.js'
|
|
3
|
+
|
|
4
|
+
describe('createApp', () => {
|
|
5
|
+
it('mounts the counter and keeps its state in data attributes', () => {
|
|
6
|
+
const root = document.createElement('div')
|
|
7
|
+
document.body.append(root)
|
|
8
|
+
const counter = createApp(root)
|
|
9
|
+
expect(root.querySelector('h1')).not.toBeNull()
|
|
10
|
+
expect(counter.element.dataset.count).toBe('0')
|
|
11
|
+
expect(counter.element.dataset.tone).toBe('idle')
|
|
12
|
+
|
|
13
|
+
counter.increment()
|
|
14
|
+
counter.increment()
|
|
15
|
+
|
|
16
|
+
expect(counter.value()).toBe(2)
|
|
17
|
+
expect(counter.element.dataset.count).toBe('2')
|
|
18
|
+
expect(counter.element.dataset.tone).toBe('active')
|
|
19
|
+
expect(counter.element.className).toBe('counter')
|
|
20
|
+
})
|
|
21
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { AppConfig } from './config.js'
|
|
2
|
+
import type { HealthProbe } from './health/health.service.js'
|
|
3
|
+
import express from 'express'
|
|
4
|
+
import { HealthController } from './health/health.controller.js'
|
|
5
|
+
import { alwaysUp, HealthService } from './health/health.service.js'
|
|
6
|
+
import { createApiContractValidator } from './http/api-contract.middleware.js'
|
|
7
|
+
import { errorHandler } from './http/error-handler.middleware.js'
|
|
8
|
+
|
|
9
|
+
export interface AppDependencies {
|
|
10
|
+
healthProbe?: HealthProbe
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createApp(config: AppConfig, dependencies: AppDependencies = {}): express.Express {
|
|
14
|
+
const app = express()
|
|
15
|
+
app.disable('x-powered-by')
|
|
16
|
+
app.use(express.json())
|
|
17
|
+
app.use(createApiContractValidator(config))
|
|
18
|
+
|
|
19
|
+
const health = new HealthService(config.serviceName, dependencies.healthProbe ?? alwaysUp)
|
|
20
|
+
|
|
21
|
+
const router = express.Router()
|
|
22
|
+
HealthController.register(router, health)
|
|
23
|
+
app.use(router)
|
|
24
|
+
|
|
25
|
+
app.use(errorHandler)
|
|
26
|
+
return app
|
|
27
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_CONTRACT_PATH = fileURLToPath(new URL('{{contractPathFromConfig}}', import.meta.url))
|
|
5
|
+
|
|
6
|
+
export interface AppConfig {
|
|
7
|
+
port: number
|
|
8
|
+
serviceName: string
|
|
9
|
+
apiContractPath: string
|
|
10
|
+
validateApiResponses: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function integer(name: string, fallback: number): number {
|
|
14
|
+
const raw = process.env[name]
|
|
15
|
+
if (raw == null || raw === '')
|
|
16
|
+
return fallback
|
|
17
|
+
const parsed = Number(raw)
|
|
18
|
+
if (!Number.isInteger(parsed))
|
|
19
|
+
throw new Error(`${name} must be an integer, got "${raw}"`)
|
|
20
|
+
return parsed
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|
24
|
+
return {
|
|
25
|
+
port: integer('PORT', 3000),
|
|
26
|
+
serviceName: env.SERVICE_NAME ?? '{{projectName}}',
|
|
27
|
+
apiContractPath: env.API_CONTRACT_PATH ?? DEFAULT_CONTRACT_PATH,
|
|
28
|
+
validateApiResponses: env.NODE_ENV !== 'production',
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { components } from '{{contractTypesImport}}'
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Router } from 'express'
|
|
2
|
+
import type { HealthService } from './health.service.js'
|
|
3
|
+
import { asyncHandler } from '../http/async-handler.middleware.js'
|
|
4
|
+
|
|
5
|
+
export class HealthController {
|
|
6
|
+
static register(router: Router, health: HealthService): void {
|
|
7
|
+
router.get('/health', asyncHandler(async (_req, res) => {
|
|
8
|
+
const snapshot = await health.snapshot()
|
|
9
|
+
res.status(snapshot.status === 'ok' ? 200 : 503).json(snapshot)
|
|
10
|
+
}))
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { components } from '../contracts/types.js'
|
|
2
|
+
|
|
3
|
+
export type HealthResponse = components['schemas']['HealthResponse']
|
|
4
|
+
|
|
5
|
+
export interface HealthProbe {
|
|
6
|
+
ping: () => Promise<boolean>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const alwaysUp: HealthProbe = {
|
|
10
|
+
ping: async () => true,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class HealthService {
|
|
14
|
+
constructor(
|
|
15
|
+
private readonly serviceName: string,
|
|
16
|
+
private readonly probe: HealthProbe,
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
async snapshot(): Promise<HealthResponse> {
|
|
20
|
+
const up = await this.probe.ping()
|
|
21
|
+
return { status: up ? 'ok' : 'error', service: this.serviceName, time: new Date().toISOString() }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RequestHandler } from 'express'
|
|
2
|
+
import type { AppConfig } from '../config.js'
|
|
3
|
+
import { middleware as openApiValidator } from 'express-openapi-validator'
|
|
4
|
+
|
|
5
|
+
export function createApiContractValidator(config: AppConfig): RequestHandler[] {
|
|
6
|
+
return openApiValidator({
|
|
7
|
+
apiSpec: config.apiContractPath,
|
|
8
|
+
validateRequests: { allowUnknownQueryParameters: true },
|
|
9
|
+
validateResponses: config.validateApiResponses,
|
|
10
|
+
validateSecurity: false,
|
|
11
|
+
ignoreUndocumented: true,
|
|
12
|
+
})
|
|
13
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export class AppError extends Error {
|
|
2
|
+
constructor(
|
|
3
|
+
readonly code: string,
|
|
4
|
+
readonly status: number,
|
|
5
|
+
message: string,
|
|
6
|
+
) {
|
|
7
|
+
super(message)
|
|
8
|
+
this.name = 'AppError'
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isAppError(error: unknown): error is AppError {
|
|
13
|
+
return error instanceof AppError
|
|
14
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { NextFunction, Request, RequestHandler, Response } from 'express'
|
|
2
|
+
|
|
3
|
+
export function asyncHandler(handler: (req: Request, res: Response, next: NextFunction) => Promise<void>): RequestHandler {
|
|
4
|
+
return (req, res, next) => {
|
|
5
|
+
handler(req, res, next).catch(next)
|
|
6
|
+
}
|
|
7
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type express from 'express'
|
|
2
|
+
import type { components } from '../contracts/types.js'
|
|
3
|
+
import { isAppError } from './app-error.js'
|
|
4
|
+
|
|
5
|
+
type ErrorResponse = components['schemas']['ErrorResponse']
|
|
6
|
+
|
|
7
|
+
interface ContractError extends Error {
|
|
8
|
+
status: number
|
|
9
|
+
errors: Array<{ path: string, message: string }>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isContractError(error: unknown): error is ContractError {
|
|
13
|
+
return error instanceof Error
|
|
14
|
+
&& typeof (error as ContractError).status === 'number'
|
|
15
|
+
&& Array.isArray((error as ContractError).errors)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function contractErrorPayload(error: ContractError): ErrorResponse {
|
|
19
|
+
const message = error.errors.map(item => `${item.path} ${item.message}`).join('; ') || error.message
|
|
20
|
+
if (error.status === 404)
|
|
21
|
+
return { error: 'NOT_FOUND', message }
|
|
22
|
+
if (error.status >= 500)
|
|
23
|
+
return { error: 'INTERNAL_ERROR', message }
|
|
24
|
+
return { error: 'VALIDATION_ERROR', message }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function errorHandler(
|
|
28
|
+
error: unknown,
|
|
29
|
+
_req: express.Request,
|
|
30
|
+
res: express.Response,
|
|
31
|
+
_next: express.NextFunction,
|
|
32
|
+
): void {
|
|
33
|
+
if (res.headersSent)
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
if (isAppError(error)) {
|
|
37
|
+
const payload: ErrorResponse = { error: error.code, message: error.message }
|
|
38
|
+
res.status(error.status).json(payload)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (isContractError(error)) {
|
|
43
|
+
res.status(error.status).json(contractErrorPayload(error))
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.error('[Unhandled error]', error)
|
|
48
|
+
const payload: ErrorResponse = { error: 'INTERNAL_ERROR', message: 'Internal server error' }
|
|
49
|
+
res.status(500).json(payload)
|
|
50
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createApp } from './app.js'
|
|
2
|
+
import { readConfig } from './config.js'
|
|
3
|
+
|
|
4
|
+
const config = readConfig()
|
|
5
|
+
const app = createApp(config)
|
|
6
|
+
|
|
7
|
+
app.listen(config.port, () => {
|
|
8
|
+
console.log(`${config.serviceName} listening on http://localhost:${config.port}`)
|
|
9
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { HealthResponse } from '../src/health/health.service.js'
|
|
2
|
+
import request from 'supertest'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { createApp } from '../src/app.js'
|
|
5
|
+
import { readConfig } from '../src/config.js'
|
|
6
|
+
|
|
7
|
+
const config = readConfig({ NODE_ENV: 'test' })
|
|
8
|
+
|
|
9
|
+
describe('health endpoint', () => {
|
|
10
|
+
it('answers ok with the contract shape', async () => {
|
|
11
|
+
const response = await request(createApp(config)).get('/health')
|
|
12
|
+
expect(response.status).toBe(200)
|
|
13
|
+
expect(response.body).toEqual({ status: 'ok', service: config.serviceName, time: expect.any(String) })
|
|
14
|
+
expect(response.headers['x-powered-by']).toBeUndefined()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('answers 503 when the probe fails', async () => {
|
|
18
|
+
const app = createApp(config, { healthProbe: { ping: async () => false } })
|
|
19
|
+
const response = await request(app).get('/health')
|
|
20
|
+
expect(response.status).toBe(503)
|
|
21
|
+
expect((response.body as HealthResponse).status).toBe('error')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('answers 404 for a route outside the contract', async () => {
|
|
25
|
+
const response = await request(createApp(config)).get('/nowhere')
|
|
26
|
+
expect(response.status).toBe(404)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from 'express'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { AppError } from '../../src/http/app-error.js'
|
|
4
|
+
import { errorHandler } from '../../src/http/error-handler.middleware.js'
|
|
5
|
+
|
|
6
|
+
interface CapturedResponse {
|
|
7
|
+
statusCode: number
|
|
8
|
+
body: unknown
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function handle(error: unknown): CapturedResponse {
|
|
12
|
+
const captured: CapturedResponse = { statusCode: 0, body: undefined }
|
|
13
|
+
const res = {
|
|
14
|
+
headersSent: false,
|
|
15
|
+
status(code: number) {
|
|
16
|
+
captured.statusCode = code
|
|
17
|
+
return this
|
|
18
|
+
},
|
|
19
|
+
json(payload: unknown) {
|
|
20
|
+
captured.body = payload
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
errorHandler(error, {} as Request, res as unknown as Response, (() => {}) as NextFunction)
|
|
24
|
+
return captured
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('errorHandler', () => {
|
|
28
|
+
it('answers an AppError with its status, code and message only', () => {
|
|
29
|
+
expect(handle(new AppError('THING_MISSING', 404, 'no such thing')))
|
|
30
|
+
.toEqual({ statusCode: 404, body: { error: 'THING_MISSING', message: 'no such thing' } })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('maps a contract validation failure to VALIDATION_ERROR with the offending paths', () => {
|
|
34
|
+
const contractError = Object.assign(new Error('request failed'), {
|
|
35
|
+
status: 400,
|
|
36
|
+
errors: [{ path: '/body/name', message: 'must be string' }],
|
|
37
|
+
})
|
|
38
|
+
expect(handle(contractError))
|
|
39
|
+
.toEqual({ statusCode: 400, body: { error: 'VALIDATION_ERROR', message: '/body/name must be string' } })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('hides the cause of an unexpected error behind a generic 500 body', () => {
|
|
43
|
+
const log = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
44
|
+
const result = handle(new Error('connect ECONNREFUSED db:5432 password=hunter2'))
|
|
45
|
+
log.mockRestore()
|
|
46
|
+
expect(result).toEqual({ statusCode: 500, body: { error: 'INTERNAL_ERROR', message: 'Internal server error' } })
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
id: http
|
|
2
|
+
title: HTTP API composition
|
|
3
|
+
description: >-
|
|
4
|
+
`createApp(config, dependencies)` is the composition root: middleware runs in the order shown, every
|
|
5
|
+
service is constructed there and handed to a controller, and nothing else constructs a service.
|
|
6
|
+
Dotted edges are wiring, solid edges are the request path.
|
|
7
|
+
doc: architecture/http.md
|
|
8
|
+
boundaries:
|
|
9
|
+
- id: middleware
|
|
10
|
+
label: Middleware, in order
|
|
11
|
+
- id: routes
|
|
12
|
+
label: Controllers by route
|
|
13
|
+
- id: services
|
|
14
|
+
label: Services
|
|
15
|
+
nodes:
|
|
16
|
+
- id: app
|
|
17
|
+
label: createApp(config, dependencies)
|
|
18
|
+
path: {{appRoot}}src/app.ts
|
|
19
|
+
boundary: middleware
|
|
20
|
+
- id: body
|
|
21
|
+
label: express.json
|
|
22
|
+
boundary: middleware
|
|
23
|
+
- id: contract
|
|
24
|
+
label: API contract validator
|
|
25
|
+
path: {{appRoot}}src/http/api-contract.middleware.ts
|
|
26
|
+
boundary: middleware
|
|
27
|
+
- id: errors
|
|
28
|
+
label: errorHandler
|
|
29
|
+
path: {{appRoot}}src/http/error-handler.middleware.ts
|
|
30
|
+
boundary: middleware
|
|
31
|
+
- id: c_health
|
|
32
|
+
label: GET /health
|
|
33
|
+
path: {{appRoot}}src/health/health.controller.ts
|
|
34
|
+
boundary: routes
|
|
35
|
+
- id: s_health
|
|
36
|
+
label: HealthService
|
|
37
|
+
path: {{appRoot}}src/health/health.service.ts
|
|
38
|
+
boundary: services
|
|
39
|
+
edges:
|
|
40
|
+
- {from: app, to: body, kind: sequence}
|
|
41
|
+
- {from: body, to: contract, kind: sequence}
|
|
42
|
+
- {from: contract, to: c_health, kind: route}
|
|
43
|
+
- {from: c_health, to: s_health, kind: sequence}
|
|
44
|
+
- {from: c_health, to: errors, kind: sequence, label: on error}
|
|
45
|
+
- {from: app, to: s_health, kind: wires, label: constructs}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# HTTP API
|
|
2
|
+
|
|
3
|
+
The request path and the wiring below are rendered from
|
|
4
|
+
[composition/http.yaml](composition/http.yaml). Edit the model, then run `pnpm composition:render`;
|
|
5
|
+
`pnpm composition:check` fails when the diagram and the model drift apart.
|
|
6
|
+
|
|
7
|
+
<!-- composition:http -->
|
|
8
|
+
`createApp(config, dependencies)` is the composition root: middleware runs in the order shown, every service is constructed there and handed to a controller, and nothing else constructs a service. Dotted edges are wiring, solid edges are the request path.
|
|
9
|
+
|
|
10
|
+
```mermaid
|
|
11
|
+
flowchart LR
|
|
12
|
+
subgraph b_middleware["Middleware, in order"]
|
|
13
|
+
app["createApp(config, dependencies)"]
|
|
14
|
+
body["express.json"]
|
|
15
|
+
contract["API contract validator"]
|
|
16
|
+
errors["errorHandler"]
|
|
17
|
+
end
|
|
18
|
+
subgraph b_routes["Controllers by route"]
|
|
19
|
+
c_health["GET /health"]
|
|
20
|
+
end
|
|
21
|
+
subgraph b_services["Services"]
|
|
22
|
+
s_health["HealthService"]
|
|
23
|
+
end
|
|
24
|
+
app --> body
|
|
25
|
+
body --> contract
|
|
26
|
+
contract --> c_health
|
|
27
|
+
c_health --> s_health
|
|
28
|
+
c_health -->|"on error"| errors
|
|
29
|
+
app -.->|"constructs"| s_health
|
|
30
|
+
```
|
|
31
|
+
<!-- /composition:http -->
|
|
32
|
+
|
|
33
|
+
## Conventions
|
|
34
|
+
|
|
35
|
+
File names carry the role: `*.controller.ts` (routing and HTTP), `*.service.ts` (business logic),
|
|
36
|
+
`*.middleware.ts` (Express middleware). One feature per directory. `src/config.ts` is the only reader
|
|
37
|
+
of `process.env`; `src/http/` is the only place that reads `req.body`, `req.query` or `req.params`
|
|
38
|
+
(both are lint rules). Expected failures throw `AppError(code, status, message)`; the global
|
|
39
|
+
`errorHandler` turns it into `{ error, message }`, and anything else becomes a 500 with a generic body.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: API contract
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
paths:
|
|
6
|
+
- contracts/**
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
breaking-changes:
|
|
13
|
+
name: Breaking changes against ${{ github.base_ref }}
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v7
|
|
17
|
+
with:
|
|
18
|
+
fetch-depth: 0
|
|
19
|
+
|
|
20
|
+
- name: Check whether the base branch has the contract
|
|
21
|
+
id: base
|
|
22
|
+
run: |
|
|
23
|
+
if git cat-file -e "origin/${{ github.base_ref }}:contracts/api/openapi.yaml" 2>/dev/null; then
|
|
24
|
+
echo "exists=true" >> "$GITHUB_OUTPUT"
|
|
25
|
+
else
|
|
26
|
+
echo "exists=false" >> "$GITHUB_OUTPUT"
|
|
27
|
+
echo "::notice::contracts/api/openapi.yaml is not on origin/${{ github.base_ref }}; skipping the breaking-change comparison"
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
- name: Compare with the base branch
|
|
31
|
+
if: steps.base.outputs.exists == 'true'
|
|
32
|
+
uses: oasdiff/oasdiff-action/breaking@v0
|
|
33
|
+
with:
|
|
34
|
+
base: origin/${{ github.base_ref }}:contracts/api/openapi.yaml
|
|
35
|
+
revision: HEAD:contracts/api/openapi.yaml
|
|
36
|
+
fail-on: ERR
|