jsonapi-err 0.0.1
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/.oxfmtrc.json +7 -0
- package/.oxlintrc.json +40 -0
- package/.vscode/extensions.json +3 -0
- package/.vscode/settings.json +5 -0
- package/AGENTS.md +208 -0
- package/LICENSE +21 -0
- package/README.md +15 -0
- package/package.json +36 -0
- package/src/core/api-error.ts +66 -0
- package/src/core/app-error.ts +14 -0
- package/src/core/format-jsonapi.ts +15 -0
- package/src/core/mapper.ts +79 -0
- package/src/http/http-errors.ts +111 -0
- package/src/http/type-guards.ts +45 -0
- package/src/index.ts +9 -0
- package/src/types.ts +27 -0
- package/tests/api-error.test.ts +117 -0
- package/tests/app-error.test.ts +31 -0
- package/tests/format-jsonapi.test.ts +37 -0
- package/tests/http-errors.test.ts +36 -0
- package/tests/mapper.test.ts +83 -0
- package/tsconfig.json +28 -0
- package/tsup.config.ts +15 -0
package/.oxfmtrc.json
ADDED
package/.oxlintrc.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
|
3
|
+
"plugins": null,
|
|
4
|
+
"categories": {},
|
|
5
|
+
"rules": {},
|
|
6
|
+
"settings": {
|
|
7
|
+
"jsx-a11y": {
|
|
8
|
+
"polymorphicPropName": null,
|
|
9
|
+
"components": {},
|
|
10
|
+
"attributes": {}
|
|
11
|
+
},
|
|
12
|
+
"next": {
|
|
13
|
+
"rootDir": []
|
|
14
|
+
},
|
|
15
|
+
"react": {
|
|
16
|
+
"formComponents": [],
|
|
17
|
+
"linkComponents": [],
|
|
18
|
+
"version": null,
|
|
19
|
+
"componentWrapperFunctions": []
|
|
20
|
+
},
|
|
21
|
+
"jsdoc": {
|
|
22
|
+
"ignorePrivate": false,
|
|
23
|
+
"ignoreInternal": false,
|
|
24
|
+
"ignoreReplacesDocs": true,
|
|
25
|
+
"overrideReplacesDocs": true,
|
|
26
|
+
"augmentsExtendsReplacesDocs": false,
|
|
27
|
+
"implementsReplacesDocs": false,
|
|
28
|
+
"exemptDestructuredRootsFromChecks": false,
|
|
29
|
+
"tagNamePreference": {}
|
|
30
|
+
},
|
|
31
|
+
"vitest": {
|
|
32
|
+
"typecheck": false
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"env": {
|
|
36
|
+
"builtin": true
|
|
37
|
+
},
|
|
38
|
+
"globals": {},
|
|
39
|
+
"ignorePatterns": []
|
|
40
|
+
}
|
package/AGENTS.md
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance for coding agents working in this repository.
|
|
4
|
+
|
|
5
|
+
## Project Snapshot
|
|
6
|
+
|
|
7
|
+
- Project name: `jsonapi-errors`
|
|
8
|
+
- Runtime/tooling: Bun
|
|
9
|
+
- Language: TypeScript (ESNext modules)
|
|
10
|
+
- Primary domain: structured application/API error types for JSON:API style responses
|
|
11
|
+
- Package type: ESM (`"type": "module"`)
|
|
12
|
+
|
|
13
|
+
## Repository Layout
|
|
14
|
+
|
|
15
|
+
- `index.ts` - current entry point
|
|
16
|
+
- `src/core/` - core error abstractions and mappings
|
|
17
|
+
- `app-error.ts`
|
|
18
|
+
- `api-error.ts`
|
|
19
|
+
- `mapper.ts`
|
|
20
|
+
- `src/http/` - HTTP-focused helpers
|
|
21
|
+
- `http-errors.ts`
|
|
22
|
+
- `type-guards.ts` (currently empty)
|
|
23
|
+
- `src/types.ts` - shared types (currently empty)
|
|
24
|
+
- Config:
|
|
25
|
+
- `package.json`
|
|
26
|
+
- `tsconfig.json`
|
|
27
|
+
- `.oxlintrc.json`
|
|
28
|
+
- `.oxfmtrc.json`
|
|
29
|
+
- `.vscode/settings.json`
|
|
30
|
+
|
|
31
|
+
## Install / Setup
|
|
32
|
+
|
|
33
|
+
- Install dependencies:
|
|
34
|
+
- `bun install`
|
|
35
|
+
|
|
36
|
+
- Recommended editor behavior (already configured in VS Code):
|
|
37
|
+
- OXC formatter as default
|
|
38
|
+
- format on save enabled
|
|
39
|
+
|
|
40
|
+
## Build / Lint / Test Commands
|
|
41
|
+
|
|
42
|
+
This repo currently has formatter and linter scripts, but no explicit build/test scripts.
|
|
43
|
+
|
|
44
|
+
### Formatting
|
|
45
|
+
|
|
46
|
+
- Format all files:
|
|
47
|
+
- `bun run format`
|
|
48
|
+
- Check formatting only:
|
|
49
|
+
- `bun run format:check`
|
|
50
|
+
|
|
51
|
+
Formatting is controlled by `.oxfmtrc.json`:
|
|
52
|
+
|
|
53
|
+
- no semicolons (`"semi": false`)
|
|
54
|
+
- single quotes (`"singleQuote": true`)
|
|
55
|
+
- consistent quoting for object keys (`"quoteProps": "consistent"`)
|
|
56
|
+
|
|
57
|
+
### Linting
|
|
58
|
+
|
|
59
|
+
- Run linter:
|
|
60
|
+
- `bun run lint`
|
|
61
|
+
- Auto-fix lint issues:
|
|
62
|
+
- `bun run lint:fix`
|
|
63
|
+
|
|
64
|
+
Lint config is intentionally minimal in `.oxlintrc.json` (few enforced custom rules).
|
|
65
|
+
|
|
66
|
+
### Type Checking (No dedicated script yet)
|
|
67
|
+
|
|
68
|
+
Because this is TypeScript with `noEmit: true`, use:
|
|
69
|
+
|
|
70
|
+
- `bunx tsc --noEmit`
|
|
71
|
+
|
|
72
|
+
If you add a script in `package.json`, prefer:
|
|
73
|
+
|
|
74
|
+
- `"typecheck": "tsc --noEmit"`
|
|
75
|
+
|
|
76
|
+
### Build
|
|
77
|
+
|
|
78
|
+
- No dedicated build pipeline currently configured.
|
|
79
|
+
- Treat this as source-first TypeScript library code unless a build tool is introduced.
|
|
80
|
+
|
|
81
|
+
### Tests
|
|
82
|
+
|
|
83
|
+
There are currently no test files in the repository. Bun test runner is available.
|
|
84
|
+
|
|
85
|
+
- Run all tests:
|
|
86
|
+
- `bun test`
|
|
87
|
+
- Run a single test file:
|
|
88
|
+
- `bun test path/to/file.test.ts`
|
|
89
|
+
- Run tests matching file name pattern:
|
|
90
|
+
- `bun test error`
|
|
91
|
+
- (matches test files whose path/name includes `error`)
|
|
92
|
+
- Run a single test by test name pattern:
|
|
93
|
+
- `bun test -t "returns jsonapi payload"`
|
|
94
|
+
- Combine single file + single test case:
|
|
95
|
+
- `bun test src/core/api-error.test.ts -t "toJsonApi"`
|
|
96
|
+
- Coverage:
|
|
97
|
+
- `bun test --coverage`
|
|
98
|
+
|
|
99
|
+
Agent guidance:
|
|
100
|
+
|
|
101
|
+
- Prefer targeted test runs first (`bun test <file>` or `-t`) before full suite.
|
|
102
|
+
- If no tests exist for changed code, consider adding minimal focused tests.
|
|
103
|
+
|
|
104
|
+
## TypeScript Configuration Expectations
|
|
105
|
+
|
|
106
|
+
`tsconfig.json` highlights:
|
|
107
|
+
|
|
108
|
+
- `strict: true` (required)
|
|
109
|
+
- `noUncheckedIndexedAccess: true` (important safety signal)
|
|
110
|
+
- `moduleResolution: "bundler"`
|
|
111
|
+
- `allowImportingTsExtensions: true`
|
|
112
|
+
- `verbatimModuleSyntax: true`
|
|
113
|
+
- `noEmit: true`
|
|
114
|
+
|
|
115
|
+
Implications:
|
|
116
|
+
|
|
117
|
+
- Maintain strict typing; do not introduce `any` unless unavoidable and documented.
|
|
118
|
+
- Preserve ESM import/export style.
|
|
119
|
+
- Keep types explicit when inference harms readability or safety.
|
|
120
|
+
|
|
121
|
+
## Code Style Guidelines
|
|
122
|
+
|
|
123
|
+
### Imports and Exports
|
|
124
|
+
|
|
125
|
+
- Prefer `import type` for type-only imports.
|
|
126
|
+
- Keep imports minimal and local to module needs.
|
|
127
|
+
- Use named exports over default exports.
|
|
128
|
+
- Preserve existing relative import style.
|
|
129
|
+
- Follow existing pattern of exporting at bottom when already used in file.
|
|
130
|
+
|
|
131
|
+
### Formatting
|
|
132
|
+
|
|
133
|
+
- Do not add semicolons.
|
|
134
|
+
- Use single quotes.
|
|
135
|
+
- Keep object/array literals readable with trailing commas where formatter applies.
|
|
136
|
+
- Let `oxfmt` decide final spacing/wrapping.
|
|
137
|
+
|
|
138
|
+
### Types
|
|
139
|
+
|
|
140
|
+
- Prefer precise types and generics over `any`.
|
|
141
|
+
- Use `unknown` for external/untrusted values, then narrow.
|
|
142
|
+
- Use `readonly` properties for immutable class state where appropriate.
|
|
143
|
+
- Keep public type contracts small and explicit (`type` aliases are common here).
|
|
144
|
+
|
|
145
|
+
### Naming Conventions
|
|
146
|
+
|
|
147
|
+
- Files: kebab-case (`api-error.ts`, `http-errors.ts`)
|
|
148
|
+
- Classes/types/interfaces: PascalCase (`ApiError`, `AppError`, `Mapping`)
|
|
149
|
+
- Variables/functions/methods: camelCase
|
|
150
|
+
- Error factory objects may use PascalCase namespace-like constants (`HttpErrors`).
|
|
151
|
+
- Error codes should be stable strings; avoid ad hoc changes once published.
|
|
152
|
+
|
|
153
|
+
### Error Handling and Domain Patterns
|
|
154
|
+
|
|
155
|
+
When implementing custom errors, follow existing patterns:
|
|
156
|
+
|
|
157
|
+
- Extend `Error` for custom classes.
|
|
158
|
+
- Call `Object.setPrototypeOf(this, new.target.prototype)` in constructor.
|
|
159
|
+
- Preserve operational vs programmer error distinction (`isOperational` flag).
|
|
160
|
+
- Keep machine-readable fields explicit:
|
|
161
|
+
- `status`, `title`, `detail`, `code`, optional `source`, `meta`, `id`
|
|
162
|
+
- Prefer safe defaults:
|
|
163
|
+
- `isOperational: true`
|
|
164
|
+
- `expose: true` only when suitable for client-facing details
|
|
165
|
+
- Preserve optional `cause` and headers metadata if applicable.
|
|
166
|
+
- Ensure JSON:API serialization shape remains stable (`{ errors: [...] }`).
|
|
167
|
+
|
|
168
|
+
### JSON:API Response Shape
|
|
169
|
+
|
|
170
|
+
For API-facing errors, maintain compatibility with this structure:
|
|
171
|
+
|
|
172
|
+
- Top level object with `errors` array
|
|
173
|
+
- Each error object generally includes:
|
|
174
|
+
- `status` (string in payload)
|
|
175
|
+
- `title`
|
|
176
|
+
- `detail`
|
|
177
|
+
- `code`
|
|
178
|
+
- Optional fields included conditionally:
|
|
179
|
+
- `id`, `source`, `meta`
|
|
180
|
+
|
|
181
|
+
Do not add fields casually; treat shape changes as contract changes.
|
|
182
|
+
|
|
183
|
+
## Agent Workflow Expectations
|
|
184
|
+
|
|
185
|
+
- Before editing: read related modules fully.
|
|
186
|
+
- After editing:
|
|
187
|
+
1. `bun run format`
|
|
188
|
+
2. `bun run lint`
|
|
189
|
+
3. `bunx tsc --noEmit`
|
|
190
|
+
4. `bun test` (or targeted test command)
|
|
191
|
+
- Prefer small, focused changes over broad refactors.
|
|
192
|
+
- Avoid introducing new dependencies unless justified.
|
|
193
|
+
|
|
194
|
+
## Cursor / Copilot Rule Status
|
|
195
|
+
|
|
196
|
+
Checked and not found at time of writing:
|
|
197
|
+
|
|
198
|
+
- `.cursor/rules/` (not present)
|
|
199
|
+
- `.cursorrules` (not present)
|
|
200
|
+
- `.github/copilot-instructions.md` (not present)
|
|
201
|
+
|
|
202
|
+
If these files are added later, update this document to incorporate them.
|
|
203
|
+
|
|
204
|
+
## Notes for Future Contributors
|
|
205
|
+
|
|
206
|
+
- `src/types.ts` and `src/http/type-guards.ts` are currently empty; align new additions with strict typing and existing naming.
|
|
207
|
+
- If adding scripts (build/test/typecheck), update this AGENTS file immediately.
|
|
208
|
+
- If introducing tests, prefer colocated or clearly named `*.test.ts` files and document single-test invocation examples.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sebastian Sala
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# jsonapi-errors
|
|
2
|
+
|
|
3
|
+
To install dependencies:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bun install
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
To run:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun run index.ts
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
This project was created using `bun init` in bun v1.2.9. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jsonapi-err",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.cjs",
|
|
7
|
+
"module": "dist/index.mjs",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.mjs",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"format": "oxfmt",
|
|
18
|
+
"format:check": "oxfmt --check",
|
|
19
|
+
"lint": "oxlint",
|
|
20
|
+
"lint:fix": "oxlint --fix",
|
|
21
|
+
"build": "tsup",
|
|
22
|
+
"prepublishOnly": "bun run build"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/bun": "latest",
|
|
26
|
+
"oxfmt": "^0.28.0",
|
|
27
|
+
"oxlint": "^1.43.0",
|
|
28
|
+
"tsup": "^8.5.1"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"typescript": "^5"
|
|
32
|
+
},
|
|
33
|
+
"lint-staged": {
|
|
34
|
+
"*": "oxfmt --no-error-on-unmatched-pattern"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { JsonApiErrorDocument, JsonApiSource, Meta } from '../types'
|
|
2
|
+
|
|
3
|
+
export type ApiErrorOptions<M = Meta, S extends JsonApiSource = JsonApiSource> = {
|
|
4
|
+
status: number
|
|
5
|
+
title: string
|
|
6
|
+
detail: string
|
|
7
|
+
code: string
|
|
8
|
+
source?: S
|
|
9
|
+
meta?: M
|
|
10
|
+
id?: string
|
|
11
|
+
|
|
12
|
+
headers?: Record<string, string>
|
|
13
|
+
expose?: boolean
|
|
14
|
+
isOperational?: boolean
|
|
15
|
+
cause?: unknown
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ApiError<M = Meta, S extends JsonApiSource = JsonApiSource> extends Error {
|
|
19
|
+
readonly status: number
|
|
20
|
+
readonly title: string
|
|
21
|
+
readonly detail: string
|
|
22
|
+
readonly code: string
|
|
23
|
+
readonly source?: ApiErrorOptions<M, S>['source']
|
|
24
|
+
readonly meta?: M
|
|
25
|
+
readonly id?: string
|
|
26
|
+
readonly isOperational: boolean
|
|
27
|
+
readonly headers: Record<string, string>
|
|
28
|
+
readonly expose: boolean
|
|
29
|
+
|
|
30
|
+
constructor(options: ApiErrorOptions<M, S>) {
|
|
31
|
+
super(options.detail, { cause: options.cause })
|
|
32
|
+
Object.setPrototypeOf(this, new.target.prototype)
|
|
33
|
+
|
|
34
|
+
this.status = options.status
|
|
35
|
+
this.title = options.title
|
|
36
|
+
this.detail = options.detail
|
|
37
|
+
this.code = options.code
|
|
38
|
+
this.source = options.source
|
|
39
|
+
this.meta = options.meta
|
|
40
|
+
this.id = options.id
|
|
41
|
+
this.isOperational = options.isOperational ?? true
|
|
42
|
+
this.headers = options.headers ?? {}
|
|
43
|
+
this.expose = options.expose ?? options.status < 500
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
toJsonApi(opts?: { sanitize?: boolean }): JsonApiErrorDocument<M, S> {
|
|
47
|
+
const sanitize = opts?.sanitize ?? (!this.expose || this.status >= 500)
|
|
48
|
+
const title = sanitize ? 'Internal Server Error' : this.title
|
|
49
|
+
const detail = sanitize ? 'An unexpected error occurred on the server.' : this.detail
|
|
50
|
+
const code = sanitize ? 'INTERNAL_SERVER_ERROR' : this.code
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
errors: [
|
|
54
|
+
{
|
|
55
|
+
status: String(this.status),
|
|
56
|
+
title,
|
|
57
|
+
detail,
|
|
58
|
+
code,
|
|
59
|
+
...(this.id ? { id: this.id } : {}),
|
|
60
|
+
...(!sanitize && this.source ? { source: this.source } : {}),
|
|
61
|
+
...(!sanitize && this.meta ? { meta: this.meta } : {}),
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Meta } from '../types'
|
|
2
|
+
|
|
3
|
+
export abstract class AppError<Code extends string = string, M = Meta> extends Error {
|
|
4
|
+
abstract readonly code: Code
|
|
5
|
+
readonly isOperational: boolean = true
|
|
6
|
+
|
|
7
|
+
readonly meta?: M
|
|
8
|
+
|
|
9
|
+
constructor(message: string, meta?: M) {
|
|
10
|
+
super(message)
|
|
11
|
+
Object.setPrototypeOf(this, new.target.prototype)
|
|
12
|
+
this.meta = meta
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ApiError } from './api-error'
|
|
2
|
+
import type { JsonApiErrorDocument, JsonApiErrorObject, JsonApiSource, Meta } from '../types'
|
|
3
|
+
|
|
4
|
+
export type { JsonApiErrorObject }
|
|
5
|
+
|
|
6
|
+
export function formatJsonApiErrors<M = Meta, S extends JsonApiSource = JsonApiSource>(
|
|
7
|
+
input: ApiError<M, S> | ApiError<M, S>[],
|
|
8
|
+
opts?: { sanitize?: boolean },
|
|
9
|
+
): JsonApiErrorDocument<M, S> {
|
|
10
|
+
const errors = Array.isArray(input) ? input : [input]
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
errors: errors.flatMap((error) => error.toJsonApi(opts).errors),
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ApiError, type ApiErrorOptions } from './api-error'
|
|
2
|
+
import { AppError } from './app-error'
|
|
3
|
+
import type { JsonApiSource, Meta } from '../types'
|
|
4
|
+
|
|
5
|
+
type AnyAppError = AppError<string, Meta>
|
|
6
|
+
type MetaOf<E extends AnyAppError> = E extends AppError<string, infer M> ? M : Meta
|
|
7
|
+
|
|
8
|
+
export type Mapping<E extends AnyAppError = AnyAppError> = {
|
|
9
|
+
status: number
|
|
10
|
+
title: string
|
|
11
|
+
expose?: boolean
|
|
12
|
+
build?: (err: E) => Partial<ApiErrorOptions<MetaOf<E>, JsonApiSource>>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ErrorMap<E extends AnyAppError = AnyAppError> = Partial<{
|
|
16
|
+
[Code in E['code']]: Mapping<Extract<E, { code: Code }>>
|
|
17
|
+
}>
|
|
18
|
+
|
|
19
|
+
export type ErrorAdapter = (err: unknown) => ApiError | undefined
|
|
20
|
+
|
|
21
|
+
export const defineErrorMap = <E extends AnyAppError>(map: ErrorMap<E>): ErrorMap<E> => map
|
|
22
|
+
|
|
23
|
+
export function createApiErrorMapper<E extends AnyAppError = AnyAppError>(
|
|
24
|
+
map: ErrorMap<E>,
|
|
25
|
+
opts?: {
|
|
26
|
+
defaultMapping?: Mapping<AnyAppError>
|
|
27
|
+
adapters?: readonly ErrorAdapter[]
|
|
28
|
+
unknownHandler?: (err: unknown) => ApiError
|
|
29
|
+
},
|
|
30
|
+
) {
|
|
31
|
+
const defaultMapping: Mapping<AnyAppError> = opts?.defaultMapping ?? {
|
|
32
|
+
status: 400,
|
|
33
|
+
title: 'Application error',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return function toApiError(err: unknown): ApiError {
|
|
37
|
+
if (err instanceof ApiError) return err
|
|
38
|
+
|
|
39
|
+
if (err instanceof AppError) {
|
|
40
|
+
const appError = err as AnyAppError
|
|
41
|
+
const mapping =
|
|
42
|
+
(map[appError.code as E['code']] as Mapping<AnyAppError> | undefined) ?? defaultMapping
|
|
43
|
+
const extra = mapping.build?.(appError)
|
|
44
|
+
|
|
45
|
+
return new ApiError({
|
|
46
|
+
status: mapping.status,
|
|
47
|
+
title: mapping.title,
|
|
48
|
+
detail: appError.message,
|
|
49
|
+
code: appError.code,
|
|
50
|
+
meta: extra?.meta ?? appError.meta,
|
|
51
|
+
source: extra?.source,
|
|
52
|
+
headers: extra?.headers,
|
|
53
|
+
id: extra?.id,
|
|
54
|
+
expose: extra?.expose ?? mapping.expose,
|
|
55
|
+
isOperational: appError.isOperational,
|
|
56
|
+
cause: extra?.cause,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (opts?.adapters) {
|
|
61
|
+
for (const adapter of opts.adapters) {
|
|
62
|
+
const adapted = adapter(err)
|
|
63
|
+
if (adapted) return adapted
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (opts?.unknownHandler) return opts.unknownHandler(err)
|
|
68
|
+
|
|
69
|
+
return new ApiError({
|
|
70
|
+
status: 500,
|
|
71
|
+
title: 'Internal Server Error',
|
|
72
|
+
detail: 'An unexpected error occurred on the server.',
|
|
73
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
74
|
+
expose: false,
|
|
75
|
+
isOperational: false,
|
|
76
|
+
cause: err,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { ApiError, type ApiErrorOptions } from '../core/api-error'
|
|
2
|
+
|
|
3
|
+
type HttpErrorSpec = readonly [status: number, title: string, defaultCode: string]
|
|
4
|
+
|
|
5
|
+
const HTTP_ERROR_SPECS = {
|
|
6
|
+
badRequest: [400, 'Bad Request', 'BAD_REQUEST'],
|
|
7
|
+
unauthorized: [401, 'Unauthorized', 'UNAUTHORIZED'],
|
|
8
|
+
paymentRequired: [402, 'Payment Required', 'PAYMENT_REQUIRED'],
|
|
9
|
+
forbidden: [403, 'Forbidden', 'FORBIDDEN'],
|
|
10
|
+
notFound: [404, 'Not Found', 'NOT_FOUND'],
|
|
11
|
+
methodNotAllowed: [405, 'Method Not Allowed', 'METHOD_NOT_ALLOWED'],
|
|
12
|
+
notAcceptable: [406, 'Not Acceptable', 'NOT_ACCEPTABLE'],
|
|
13
|
+
proxyAuthenticationRequired: [
|
|
14
|
+
407,
|
|
15
|
+
'Proxy Authentication Required',
|
|
16
|
+
'PROXY_AUTHENTICATION_REQUIRED',
|
|
17
|
+
],
|
|
18
|
+
requestTimeout: [408, 'Request Timeout', 'REQUEST_TIMEOUT'],
|
|
19
|
+
conflict: [409, 'Conflict', 'CONFLICT'],
|
|
20
|
+
gone: [410, 'Gone', 'GONE'],
|
|
21
|
+
lengthRequired: [411, 'Length Required', 'LENGTH_REQUIRED'],
|
|
22
|
+
preconditionFailed: [412, 'Precondition Failed', 'PRECONDITION_FAILED'],
|
|
23
|
+
payloadTooLarge: [413, 'Payload Too Large', 'PAYLOAD_TOO_LARGE'],
|
|
24
|
+
uriTooLong: [414, 'URI Too Long', 'URI_TOO_LONG'],
|
|
25
|
+
unsupportedMediaType: [415, 'Unsupported Media Type', 'UNSUPPORTED_MEDIA_TYPE'],
|
|
26
|
+
rangeNotSatisfiable: [416, 'Range Not Satisfiable', 'RANGE_NOT_SATISFIABLE'],
|
|
27
|
+
expectationFailed: [417, 'Expectation Failed', 'EXPECTATION_FAILED'],
|
|
28
|
+
imATeapot: [418, "I'm a teapot", 'IM_A_TEAPOT'],
|
|
29
|
+
misdirectedRequest: [421, 'Misdirected Request', 'MISDIRECTED_REQUEST'],
|
|
30
|
+
unprocessableEntity: [422, 'Unprocessable Entity', 'UNPROCESSABLE_ENTITY'],
|
|
31
|
+
locked: [423, 'Locked', 'LOCKED'],
|
|
32
|
+
failedDependency: [424, 'Failed Dependency', 'FAILED_DEPENDENCY'],
|
|
33
|
+
tooEarly: [425, 'Too Early', 'TOO_EARLY'],
|
|
34
|
+
upgradeRequired: [426, 'Upgrade Required', 'UPGRADE_REQUIRED'],
|
|
35
|
+
preconditionRequired: [428, 'Precondition Required', 'PRECONDITION_REQUIRED'],
|
|
36
|
+
tooManyRequests: [429, 'Too Many Requests', 'TOO_MANY_REQUESTS'],
|
|
37
|
+
requestHeaderFieldsTooLarge: [
|
|
38
|
+
431,
|
|
39
|
+
'Request Header Fields Too Large',
|
|
40
|
+
'REQUEST_HEADER_FIELDS_TOO_LARGE',
|
|
41
|
+
],
|
|
42
|
+
unavailableForLegalReasons: [
|
|
43
|
+
451,
|
|
44
|
+
'Unavailable For Legal Reasons',
|
|
45
|
+
'UNAVAILABLE_FOR_LEGAL_REASONS',
|
|
46
|
+
],
|
|
47
|
+
internalServerError: [500, 'Internal Server Error', 'INTERNAL_SERVER_ERROR'],
|
|
48
|
+
notImplemented: [501, 'Not Implemented', 'NOT_IMPLEMENTED'],
|
|
49
|
+
badGateway: [502, 'Bad Gateway', 'BAD_GATEWAY'],
|
|
50
|
+
serviceUnavailable: [503, 'Service Unavailable', 'SERVICE_UNAVAILABLE'],
|
|
51
|
+
gatewayTimeout: [504, 'Gateway Timeout', 'GATEWAY_TIMEOUT'],
|
|
52
|
+
httpVersionNotSupported: [505, 'HTTP Version Not Supported', 'HTTP_VERSION_NOT_SUPPORTED'],
|
|
53
|
+
variantAlsoNegotiates: [506, 'Variant Also Negotiates', 'VARIANT_ALSO_NEGOTIATES'],
|
|
54
|
+
insufficientStorage: [507, 'Insufficient Storage', 'INSUFFICIENT_STORAGE'],
|
|
55
|
+
loopDetected: [508, 'Loop Detected', 'LOOP_DETECTED'],
|
|
56
|
+
notExtended: [510, 'Not Extended', 'NOT_EXTENDED'],
|
|
57
|
+
networkAuthenticationRequired: [
|
|
58
|
+
511,
|
|
59
|
+
'Network Authentication Required',
|
|
60
|
+
'NETWORK_AUTHENTICATION_REQUIRED',
|
|
61
|
+
],
|
|
62
|
+
} as const satisfies Record<string, HttpErrorSpec>
|
|
63
|
+
|
|
64
|
+
export type HttpErrorName = keyof typeof HTTP_ERROR_SPECS
|
|
65
|
+
export type HttpErrorOptions = Partial<Omit<ApiErrorOptions, 'status' | 'title'>>
|
|
66
|
+
|
|
67
|
+
export type HttpErrorFactories = {
|
|
68
|
+
[Name in HttpErrorName]: (options?: HttpErrorOptions) => ApiError
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const createHttpError = (
|
|
72
|
+
status: number,
|
|
73
|
+
title: string,
|
|
74
|
+
defaultCode: string,
|
|
75
|
+
options: HttpErrorOptions = {},
|
|
76
|
+
): ApiError => {
|
|
77
|
+
return new ApiError({
|
|
78
|
+
status,
|
|
79
|
+
title,
|
|
80
|
+
detail: options.detail ?? title,
|
|
81
|
+
code: options.code ?? defaultCode,
|
|
82
|
+
source: options.source,
|
|
83
|
+
meta: options.meta,
|
|
84
|
+
id: options.id,
|
|
85
|
+
headers: options.headers,
|
|
86
|
+
expose: options.expose,
|
|
87
|
+
isOperational: options.isOperational,
|
|
88
|
+
cause: options.cause,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const createHttpErrorByName = (
|
|
93
|
+
name: HttpErrorName,
|
|
94
|
+
options?: HttpErrorOptions,
|
|
95
|
+
): ApiError => {
|
|
96
|
+
const [status, title, defaultCode] = HTTP_ERROR_SPECS[name]
|
|
97
|
+
return createHttpError(status, title, defaultCode, options)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const factories = Object.entries(HTTP_ERROR_SPECS).map(([name, [status, title, defaultCode]]) => {
|
|
101
|
+
return [
|
|
102
|
+
name,
|
|
103
|
+
(options?: HttpErrorOptions) => createHttpError(status, title, defaultCode, options),
|
|
104
|
+
] as const
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
export const HttpErrors = Object.fromEntries(factories) as HttpErrorFactories
|
|
108
|
+
|
|
109
|
+
export const NotFound = (options?: HttpErrorOptions): ApiError => HttpErrors.notFound(options)
|
|
110
|
+
export const InternalServerError = (options?: HttpErrorOptions): ApiError =>
|
|
111
|
+
HttpErrors.internalServerError(options)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ApiError } from '../core/api-error'
|
|
2
|
+
import { AppError } from '../core/app-error'
|
|
3
|
+
import type { JsonApiErrorDocument, JsonApiErrorObject } from '../types'
|
|
4
|
+
|
|
5
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
6
|
+
typeof value === 'object' && value !== null
|
|
7
|
+
|
|
8
|
+
const isOptionalString = (value: unknown): value is string | undefined =>
|
|
9
|
+
value === undefined || typeof value === 'string'
|
|
10
|
+
|
|
11
|
+
const isOptionalRecord = (value: unknown): value is Record<string, unknown> | undefined =>
|
|
12
|
+
value === undefined || isRecord(value)
|
|
13
|
+
|
|
14
|
+
export const isApiError = (e: unknown): e is ApiError => e instanceof ApiError
|
|
15
|
+
export const isAppError = (e: unknown): e is AppError => e instanceof AppError
|
|
16
|
+
|
|
17
|
+
export const isOperationalError = (e: unknown): e is { isOperational: boolean } =>
|
|
18
|
+
isRecord(e) && 'isOperational' in e && typeof e.isOperational === 'boolean'
|
|
19
|
+
|
|
20
|
+
export const isErrorLike = (e: unknown): e is { name: string; message: string; stack?: string } =>
|
|
21
|
+
isRecord(e) &&
|
|
22
|
+
typeof e.name === 'string' &&
|
|
23
|
+
typeof e.message === 'string' &&
|
|
24
|
+
isOptionalString(e.stack)
|
|
25
|
+
|
|
26
|
+
export const isJsonApiErrorObject = (input: unknown): input is JsonApiErrorObject => {
|
|
27
|
+
if (!isRecord(input)) return false
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
isOptionalString(input.id) &&
|
|
31
|
+
isOptionalString(input.status) &&
|
|
32
|
+
isOptionalString(input.code) &&
|
|
33
|
+
isOptionalString(input.title) &&
|
|
34
|
+
isOptionalString(input.detail) &&
|
|
35
|
+
isOptionalRecord(input.source) &&
|
|
36
|
+
isOptionalRecord(input.meta) &&
|
|
37
|
+
isOptionalRecord(input.links)
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const isJsonApiErrorDocument = (input: unknown): input is JsonApiErrorDocument => {
|
|
42
|
+
if (!isRecord(input) || !Array.isArray(input.errors)) return false
|
|
43
|
+
|
|
44
|
+
return input.errors.every(isJsonApiErrorObject)
|
|
45
|
+
}
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type Meta = Record<string, unknown>
|
|
2
|
+
|
|
3
|
+
export type JsonApiSource = {
|
|
4
|
+
pointer?: string
|
|
5
|
+
parameter?: string
|
|
6
|
+
header?: string
|
|
7
|
+
} & Record<string, unknown>
|
|
8
|
+
|
|
9
|
+
export type JsonApiLinks = {
|
|
10
|
+
about?: string
|
|
11
|
+
type?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type JsonApiErrorObject<M = Meta, S extends JsonApiSource = JsonApiSource> = {
|
|
15
|
+
id?: string
|
|
16
|
+
links?: JsonApiLinks
|
|
17
|
+
status?: string
|
|
18
|
+
code?: string
|
|
19
|
+
title?: string
|
|
20
|
+
detail?: string
|
|
21
|
+
source?: S
|
|
22
|
+
meta?: M
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type JsonApiErrorDocument<M = Meta, S extends JsonApiSource = JsonApiSource> = {
|
|
26
|
+
errors: JsonApiErrorObject<M, S>[]
|
|
27
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { expect, test, describe } from 'bun:test'
|
|
2
|
+
import { ApiError } from '../src/core/api-error'
|
|
3
|
+
|
|
4
|
+
describe('ApiError', () => {
|
|
5
|
+
test('should create an ApiError with basic options', () => {
|
|
6
|
+
const error = new ApiError({
|
|
7
|
+
status: 400,
|
|
8
|
+
title: 'Bad Request',
|
|
9
|
+
detail: 'Invalid input',
|
|
10
|
+
code: 'BAD_REQUEST',
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
expect(error.status).toBe(400)
|
|
14
|
+
expect(error.title).toBe('Bad Request')
|
|
15
|
+
expect(error.detail).toBe('Invalid input')
|
|
16
|
+
expect(error.code).toBe('BAD_REQUEST')
|
|
17
|
+
expect(error.isOperational).toBe(true)
|
|
18
|
+
expect(error.expose).toBe(true)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('should expose: false for 500 errors by default', () => {
|
|
22
|
+
const error = new ApiError({
|
|
23
|
+
status: 500,
|
|
24
|
+
title: 'Internal Error',
|
|
25
|
+
detail: 'Something went wrong',
|
|
26
|
+
code: 'INTERNAL_ERROR',
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
expect(error.expose).toBe(false)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('should allow overriding expose and isOperational', () => {
|
|
33
|
+
const error = new ApiError({
|
|
34
|
+
status: 400,
|
|
35
|
+
title: 'Bad Request',
|
|
36
|
+
detail: 'Invalid input',
|
|
37
|
+
code: 'BAD_REQUEST',
|
|
38
|
+
expose: false,
|
|
39
|
+
isOperational: false,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
expect(error.expose).toBe(false)
|
|
43
|
+
expect(error.isOperational).toBe(false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('should store meta and source', () => {
|
|
47
|
+
const meta = { userId: 123 }
|
|
48
|
+
const source = { pointer: '/data/attributes/name' }
|
|
49
|
+
const error = new ApiError({
|
|
50
|
+
status: 422,
|
|
51
|
+
title: 'Validation Error',
|
|
52
|
+
detail: 'Invalid name',
|
|
53
|
+
code: 'VALIDATION_ERROR',
|
|
54
|
+
meta,
|
|
55
|
+
source,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
expect(error.meta).toEqual(meta)
|
|
59
|
+
expect(error.source).toEqual(source)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('toJsonApi should return JSON:API compliant structure', () => {
|
|
63
|
+
const error = new ApiError({
|
|
64
|
+
status: 400,
|
|
65
|
+
title: 'Bad Request',
|
|
66
|
+
detail: 'Invalid input',
|
|
67
|
+
code: 'BAD_REQUEST',
|
|
68
|
+
id: 'error-id',
|
|
69
|
+
meta: { foo: 'bar' },
|
|
70
|
+
source: { parameter: 'query' },
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const json = error.toJsonApi()
|
|
74
|
+
|
|
75
|
+
expect(json).toEqual({
|
|
76
|
+
errors: [
|
|
77
|
+
{
|
|
78
|
+
status: '400',
|
|
79
|
+
title: 'Bad Request',
|
|
80
|
+
detail: 'Invalid input',
|
|
81
|
+
code: 'BAD_REQUEST',
|
|
82
|
+
id: 'error-id',
|
|
83
|
+
meta: { foo: 'bar' },
|
|
84
|
+
source: { parameter: 'query' },
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('toJsonApi should sanitize internal errors when status >= 500', () => {
|
|
91
|
+
const error = new ApiError({
|
|
92
|
+
status: 500,
|
|
93
|
+
title: 'Database Crash',
|
|
94
|
+
detail: 'SQL logic error at line 42',
|
|
95
|
+
code: 'DB_CRASH',
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const json = error.toJsonApi()
|
|
99
|
+
|
|
100
|
+
expect(json.errors[0]!.title).toBe('Internal Server Error')
|
|
101
|
+
expect(json.errors[0]!.detail).toBe('An unexpected error occurred on the server.')
|
|
102
|
+
expect(json.errors[0]!.code).toBe('INTERNAL_SERVER_ERROR')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('toJsonApi should allow forcing sanitize', () => {
|
|
106
|
+
const error = new ApiError({
|
|
107
|
+
status: 400,
|
|
108
|
+
title: 'Visible Error',
|
|
109
|
+
detail: 'Detailed message',
|
|
110
|
+
code: 'VISIBLE',
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const json = error.toJsonApi({ sanitize: true })
|
|
114
|
+
|
|
115
|
+
expect(json.errors[0]!.title).toBe('Internal Server Error')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { expect, test, describe } from 'bun:test'
|
|
2
|
+
import { AppError } from '../src/core/app-error'
|
|
3
|
+
|
|
4
|
+
describe('AppError', () => {
|
|
5
|
+
class TestError extends AppError<'TEST_CODE', { foo: string }> {
|
|
6
|
+
readonly code = 'TEST_CODE'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
test('should create and store code and meta with full type inference', () => {
|
|
10
|
+
const meta = { foo: 'bar' }
|
|
11
|
+
const error = new TestError('message', meta)
|
|
12
|
+
|
|
13
|
+
expect(error.message).toBe('message')
|
|
14
|
+
expect(error.code).toBe('TEST_CODE')
|
|
15
|
+
expect(error.meta).toBe(meta)
|
|
16
|
+
expect(error.meta?.foo).toBe('bar') // Verify autocomplete-like access
|
|
17
|
+
expect(error.isOperational).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('should work with interfaces for meta', () => {
|
|
21
|
+
interface MyMeta {
|
|
22
|
+
count: number
|
|
23
|
+
}
|
|
24
|
+
class InterfaceError extends AppError<'INT_CODE', MyMeta> {
|
|
25
|
+
readonly code = 'INT_CODE'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const error = new InterfaceError('msg', { count: 42 })
|
|
29
|
+
expect(error.meta?.count).toBe(42)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { expect, test, describe } from 'bun:test'
|
|
2
|
+
import { ApiError } from '../src/core/api-error'
|
|
3
|
+
import { formatJsonApiErrors } from '../src/core/format-jsonapi'
|
|
4
|
+
|
|
5
|
+
describe('formatJsonApiErrors', () => {
|
|
6
|
+
const err1 = new ApiError({
|
|
7
|
+
status: 400,
|
|
8
|
+
title: 'Error 1',
|
|
9
|
+
detail: 'Detail 1',
|
|
10
|
+
code: 'CODE1',
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const err2 = new ApiError({
|
|
14
|
+
status: 404,
|
|
15
|
+
title: 'Error 2',
|
|
16
|
+
detail: 'Detail 2',
|
|
17
|
+
code: 'CODE2',
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('should format a single error', () => {
|
|
21
|
+
const result = formatJsonApiErrors(err1)
|
|
22
|
+
expect(result.errors).toHaveLength(1)
|
|
23
|
+
expect(result.errors[0]!.status).toBe('400')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('should format an array of errors', () => {
|
|
27
|
+
const result = formatJsonApiErrors([err1, err2])
|
|
28
|
+
expect(result.errors).toHaveLength(2)
|
|
29
|
+
expect(result.errors[0]!.code).toBe('CODE1')
|
|
30
|
+
expect(result.errors[1]!.code).toBe('CODE2')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('should sanitize errors globally', () => {
|
|
34
|
+
const result = formatJsonApiErrors(err1, { sanitize: true })
|
|
35
|
+
expect(result.errors[0]!.title).toBe('Internal Server Error')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { expect, test, describe } from 'bun:test'
|
|
2
|
+
import { HttpErrors, NotFound, InternalServerError } from '../src/http/http-errors'
|
|
3
|
+
import { ApiError } from '../src/core/api-error'
|
|
4
|
+
|
|
5
|
+
describe('HttpErrors', () => {
|
|
6
|
+
test('NotFound should create a 404 error', () => {
|
|
7
|
+
const err = NotFound({ detail: 'Where?' })
|
|
8
|
+
expect(err instanceof ApiError).toBe(true)
|
|
9
|
+
expect(err.status).toBe(404)
|
|
10
|
+
expect(err.title).toBe('Not Found')
|
|
11
|
+
expect(err.detail).toBe('Where?')
|
|
12
|
+
expect(err.code).toBe('NOT_FOUND')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('InternalServerError should create a 500 error and not expose it', () => {
|
|
16
|
+
const err = InternalServerError()
|
|
17
|
+
expect(err.status).toBe(500)
|
|
18
|
+
expect(err.expose).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('should allow custom code and meta in HTTP errors', () => {
|
|
22
|
+
const err = HttpErrors.badRequest({
|
|
23
|
+
code: 'CUSTOM_BAD_REQUEST',
|
|
24
|
+
meta: { foo: 'bar' },
|
|
25
|
+
})
|
|
26
|
+
expect(err.code).toBe('CUSTOM_BAD_REQUEST')
|
|
27
|
+
expect(err.meta).toEqual({ foo: 'bar' })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('factory should have multiple status codes', () => {
|
|
31
|
+
expect(HttpErrors.forbidden().status).toBe(403)
|
|
32
|
+
expect(HttpErrors.unauthorized().status).toBe(401)
|
|
33
|
+
expect(HttpErrors.conflict().status).toBe(409)
|
|
34
|
+
expect(HttpErrors.unprocessableEntity().status).toBe(422)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { expect, test, describe } from 'bun:test'
|
|
2
|
+
import { AppError } from '../src/core/app-error'
|
|
3
|
+
import { createApiErrorMapper } from '../src/core/mapper'
|
|
4
|
+
import { ApiError } from '../src/core/api-error'
|
|
5
|
+
|
|
6
|
+
describe('createApiErrorMapper', () => {
|
|
7
|
+
class UserNotFound extends AppError<'USER_NOT_FOUND'> {
|
|
8
|
+
readonly code = 'USER_NOT_FOUND'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const mapper = createApiErrorMapper({
|
|
12
|
+
USER_NOT_FOUND: {
|
|
13
|
+
status: 404,
|
|
14
|
+
title: 'User not found',
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('should map an AppError to an ApiError based on code', () => {
|
|
19
|
+
const appError = new UserNotFound('The user does not exist')
|
|
20
|
+
const apiError = mapper(appError)
|
|
21
|
+
|
|
22
|
+
expect(apiError instanceof ApiError).toBe(true)
|
|
23
|
+
expect(apiError.status).toBe(404)
|
|
24
|
+
expect(apiError.title).toBe('User not found')
|
|
25
|
+
expect(apiError.detail).toBe('The user does not exist')
|
|
26
|
+
expect(apiError.code).toBe('USER_NOT_FOUND')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('should use default mapping for unknown AppErrors', () => {
|
|
30
|
+
class UnknownError extends AppError<'UNKNOWN'> {
|
|
31
|
+
readonly code = 'UNKNOWN'
|
|
32
|
+
}
|
|
33
|
+
const error = mapper(new UnknownError('msg'))
|
|
34
|
+
expect(error.status).toBe(400)
|
|
35
|
+
expect(error.title).toBe('Application error')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('should handle completely unknown errors with 500', () => {
|
|
39
|
+
const error = mapper(new Error('Boom'))
|
|
40
|
+
expect(error.status).toBe(500)
|
|
41
|
+
expect(error.code).toBe('INTERNAL_SERVER_ERROR')
|
|
42
|
+
expect(error.expose).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('should use custom unknownHandler if provided', () => {
|
|
46
|
+
const customMapper = createApiErrorMapper(
|
|
47
|
+
{},
|
|
48
|
+
{
|
|
49
|
+
unknownHandler: (err) =>
|
|
50
|
+
new ApiError({
|
|
51
|
+
status: 418,
|
|
52
|
+
title: 'Teapot',
|
|
53
|
+
detail: 'Indeed',
|
|
54
|
+
code: 'TEAPOT',
|
|
55
|
+
}),
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const error = customMapper(new Error('Whoops'))
|
|
60
|
+
expect(error.status).toBe(418)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('should use mapping.build for complex errors', () => {
|
|
64
|
+
class ValidationError extends AppError<'VAL_ERR', { field: string }> {
|
|
65
|
+
readonly code = 'VAL_ERR'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const complexMapper = createApiErrorMapper({
|
|
69
|
+
VAL_ERR: {
|
|
70
|
+
status: 422,
|
|
71
|
+
title: 'Validation Failed',
|
|
72
|
+
build: (err: ValidationError) => ({
|
|
73
|
+
source: { pointer: `/data/attributes/${err.meta?.field}` },
|
|
74
|
+
}),
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const err = new ValidationError('Bad email', { field: 'email' })
|
|
79
|
+
const apiErr = complexMapper(err)
|
|
80
|
+
|
|
81
|
+
expect(apiErr.source?.pointer).toBe('/data/attributes/email')
|
|
82
|
+
})
|
|
83
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup & latest features
|
|
4
|
+
"lib": ["ESNext"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
|
|
11
|
+
// Bundler mode
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"verbatimModuleSyntax": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
// Best practices
|
|
18
|
+
"strict": true,
|
|
19
|
+
"skipLibCheck": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedIndexedAccess": true,
|
|
22
|
+
|
|
23
|
+
// Some stricter flags (disabled by default)
|
|
24
|
+
"noUnusedLocals": false,
|
|
25
|
+
"noUnusedParameters": false,
|
|
26
|
+
"noPropertyAccessFromIndexSignature": false
|
|
27
|
+
}
|
|
28
|
+
}
|
package/tsup.config.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineConfig } from 'tsup'
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
entry: ['src/index.ts'],
|
|
5
|
+
format: ['esm', 'cjs'],
|
|
6
|
+
dts: true,
|
|
7
|
+
clean: true,
|
|
8
|
+
sourcemap: true,
|
|
9
|
+
outDir: 'dist',
|
|
10
|
+
outExtension({ format }) {
|
|
11
|
+
return {
|
|
12
|
+
js: format === 'esm' ? '.mjs' : '.cjs',
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
})
|