@tpzdsp/next-toolkit 1.5.0 → 1.7.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/README.md +23 -46
- package/package.json +20 -3
- package/src/components/ErrorBoundary/ErrorFallback.stories.tsx +2 -2
- package/src/components/ErrorBoundary/ErrorFallback.test.tsx +2 -2
- package/src/components/ErrorBoundary/ErrorFallback.tsx +21 -5
- package/src/components/Modal/Modal.stories.tsx +27 -90
- package/src/components/Modal/Modal.test.tsx +1 -1
- package/src/components/Modal/Modal.tsx +53 -40
- package/src/components/SlidingPanel/SlidingPanel.tsx +88 -96
- package/src/components/backToTop/BackToTop.tsx +3 -1
- package/src/components/dropdown/useDropdownMenu.ts +11 -6
- package/src/components/skipLink/SkipLink.tsx +4 -3
- package/src/errors/ApiError.ts +97 -23
- package/src/http/constants.ts +111 -0
- package/src/http/fetch.ts +263 -0
- package/src/http/index.ts +6 -0
- package/src/http/logger.ts +163 -0
- package/src/http/proxy.ts +269 -0
- package/src/http/query.ts +287 -0
- package/src/http/stream.ts +77 -0
- package/src/map/LayerSwitcherControl.ts +4 -2
- package/src/map/useKeyboardDrawing.ts +1 -1
- package/src/map/utils.ts +0 -11
- package/src/types/api.ts +25 -0
- package/src/utils/constants.ts +12 -0
- package/src/utils/http.ts +2 -30
- package/src/utils/schema.ts +30 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect, type ReactNode, useRef, useMemo } from 'react';
|
|
3
|
+
import { useState, useEffect, type ReactNode, useRef, useMemo, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
import { KeyboardKeys } from '../../utils';
|
|
4
6
|
|
|
5
7
|
type Position = 'center-left' | 'center-right' | 'center-top' | 'center-bottom';
|
|
6
8
|
|
|
@@ -11,6 +13,9 @@ export type SlidingPanelProps = {
|
|
|
11
13
|
defaultOpen?: boolean;
|
|
12
14
|
};
|
|
13
15
|
|
|
16
|
+
const TRAP_SELECTORS =
|
|
17
|
+
'a[href]:not([aria-hidden="true"]):not([hidden]), button:not([disabled]):not([aria-hidden="true"]):not([hidden]), input:not([disabled]):not([aria-hidden="true"]):not([hidden]), textarea:not([disabled]):not([aria-hidden="true"]):not([hidden]), select:not([disabled]):not([aria-hidden="true"]):not([hidden]), [tabindex]:not([tabindex="-1"]):not([aria-hidden="true"]):not([hidden])';
|
|
18
|
+
|
|
14
19
|
export const SlidingPanel = ({
|
|
15
20
|
children,
|
|
16
21
|
tabLabel = 'Open',
|
|
@@ -20,67 +25,16 @@ export const SlidingPanel = ({
|
|
|
20
25
|
const [isVisible, setIsVisible] = useState(defaultOpen);
|
|
21
26
|
const [panelDimensions, setPanelDimensions] = useState({ width: 0, height: 0 });
|
|
22
27
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
// Focus trap: cycle focus within panel when open
|
|
26
|
-
useEffect(() => {
|
|
27
|
-
if (!isVisible || !panelRef.current) {
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const panel = panelRef.current;
|
|
32
|
-
const focusableSelectors = [
|
|
33
|
-
'a[href]',
|
|
34
|
-
'button:not([disabled])',
|
|
35
|
-
'textarea:not([disabled])',
|
|
36
|
-
'input:not([disabled])',
|
|
37
|
-
'select:not([disabled])',
|
|
38
|
-
'[tabindex]:not([tabindex="-1"])',
|
|
39
|
-
];
|
|
40
|
-
const getFocusable = () =>
|
|
41
|
-
Array.from(panel.querySelectorAll<HTMLElement>(focusableSelectors.join(','))).filter(
|
|
42
|
-
(el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden'),
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
const handleKeyDown = (e: KeyboardEvent) => {
|
|
46
|
-
if (e.key === 'Escape') {
|
|
47
|
-
setIsVisible(false);
|
|
48
|
-
// Return focus to toggle button after closing
|
|
49
|
-
toggleBtnRef.current?.focus();
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (e.key === 'Tab') {
|
|
53
|
-
const focusableEls = getFocusable();
|
|
54
|
-
|
|
55
|
-
if (focusableEls.length === 0) {
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const first = focusableEls[0];
|
|
60
|
-
const last = focusableEls[focusableEls.length - 1];
|
|
61
|
-
|
|
62
|
-
if (!e.shiftKey && document.activeElement === last) {
|
|
63
|
-
e.preventDefault();
|
|
64
|
-
first.focus();
|
|
65
|
-
} else if (e.shiftKey && document.activeElement === first) {
|
|
66
|
-
e.preventDefault();
|
|
67
|
-
last.focus();
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
panel.addEventListener('keydown', handleKeyDown);
|
|
73
|
-
// Focus the first focusable element in the panel
|
|
74
|
-
const focusableEls = getFocusable();
|
|
28
|
+
const triggerRef = useRef<HTMLElement | null>(null); // store previously focused element
|
|
75
29
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
30
|
+
const openPanel = () => {
|
|
31
|
+
triggerRef.current = document.activeElement as HTMLElement;
|
|
32
|
+
setIsVisible(true);
|
|
33
|
+
};
|
|
79
34
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}, [isVisible]);
|
|
35
|
+
const closePanel = useCallback(() => {
|
|
36
|
+
setIsVisible(false);
|
|
37
|
+
}, []);
|
|
84
38
|
|
|
85
39
|
// Measure panel dimensions when visible
|
|
86
40
|
useEffect(() => {
|
|
@@ -112,44 +66,81 @@ export const SlidingPanel = ({
|
|
|
112
66
|
}
|
|
113
67
|
}, [isVisible, panelDimensions.height, panelDimensions.width]);
|
|
114
68
|
|
|
69
|
+
// move focus into panel when opened, restore focus to correct element when closed
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (isVisible && panelRef.current) {
|
|
72
|
+
const els = Array.from(panelRef.current.querySelectorAll<HTMLElement>(TRAP_SELECTORS));
|
|
73
|
+
|
|
74
|
+
if (els.length > 0) {
|
|
75
|
+
els[0].focus();
|
|
76
|
+
} else {
|
|
77
|
+
panelRef.current.focus();
|
|
78
|
+
}
|
|
79
|
+
} else if (!isVisible && triggerRef.current) {
|
|
80
|
+
triggerRef.current.focus();
|
|
81
|
+
}
|
|
82
|
+
}, [isVisible]);
|
|
83
|
+
|
|
84
|
+
// trap focus while open
|
|
115
85
|
useEffect(() => {
|
|
116
|
-
if (!panelRef.current) {
|
|
86
|
+
if (!isVisible || !panelRef.current) {
|
|
117
87
|
return;
|
|
118
88
|
}
|
|
119
89
|
|
|
120
|
-
const
|
|
121
|
-
'a[href]',
|
|
122
|
-
'button:not([disabled])',
|
|
123
|
-
'textarea:not([disabled])',
|
|
124
|
-
'input:not([disabled])',
|
|
125
|
-
'select:not([disabled])',
|
|
126
|
-
'[tabindex]:not([tabindex="-1"])',
|
|
127
|
-
];
|
|
128
|
-
const focusableEls = Array.from(
|
|
129
|
-
panelRef.current.querySelectorAll<HTMLElement>(focusableSelectors.join(',')),
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
if (!isVisible) {
|
|
133
|
-
// Remove from tab order
|
|
134
|
-
focusableEls.forEach((el) => {
|
|
135
|
-
el.dataset.prevTabIndex = el.getAttribute('tabindex') ?? '';
|
|
136
|
-
el.setAttribute('tabindex', '-1');
|
|
137
|
-
});
|
|
138
|
-
} else {
|
|
139
|
-
// Restore previous tabIndex
|
|
140
|
-
focusableEls.forEach((el) => {
|
|
141
|
-
if (el.dataset.prevTabIndex !== undefined) {
|
|
142
|
-
if (el.dataset.prevTabIndex === '') {
|
|
143
|
-
el.removeAttribute('tabindex');
|
|
144
|
-
} else {
|
|
145
|
-
el.setAttribute('tabindex', el.dataset.prevTabIndex);
|
|
146
|
-
}
|
|
90
|
+
const panel = panelRef.current;
|
|
147
91
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
92
|
+
const moveFocus = (direction: 'next' | 'prev') => {
|
|
93
|
+
const els = Array.from(panel.querySelectorAll<HTMLElement>(TRAP_SELECTORS));
|
|
94
|
+
|
|
95
|
+
if (!els.length) {
|
|
96
|
+
panel.focus();
|
|
97
|
+
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const first = els[0];
|
|
102
|
+
const last = els[els.length - 1];
|
|
103
|
+
const active = document.activeElement;
|
|
104
|
+
|
|
105
|
+
if (direction === 'next' && active === last) {
|
|
106
|
+
first.focus();
|
|
107
|
+
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (direction === 'prev' && active === first) {
|
|
112
|
+
last.focus();
|
|
113
|
+
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return false;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const handleKey = (event: KeyboardEvent) => {
|
|
121
|
+
if (event.key === KeyboardKeys.Escape) {
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
closePanel();
|
|
124
|
+
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (event.key !== KeyboardKeys.Tab) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// only if focus was moved do we prevent the default behaviour
|
|
133
|
+
if (moveFocus(event.shiftKey ? 'prev' : 'next')) {
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
panel.addEventListener('keydown', handleKey);
|
|
139
|
+
|
|
140
|
+
return () => {
|
|
141
|
+
panel.removeEventListener('keydown', handleKey);
|
|
142
|
+
};
|
|
143
|
+
}, [closePanel, isVisible]);
|
|
153
144
|
|
|
154
145
|
const panelBase =
|
|
155
146
|
'absolute bg-white shadow-lg p-4 flex flex-col transition-transform duration-300 ease-in-out overflow-auto z-30';
|
|
@@ -211,12 +202,11 @@ export const SlidingPanel = ({
|
|
|
211
202
|
}, [isVisible, panelDimensions.height, panelDimensions.width, position]);
|
|
212
203
|
|
|
213
204
|
return (
|
|
214
|
-
<div className="absolute inset-0 overflow-hidden pointer-events-none
|
|
205
|
+
<div className="absolute inset-0 z-30 overflow-hidden pointer-events-none sliding-panel">
|
|
215
206
|
<button
|
|
216
|
-
ref={toggleBtnRef}
|
|
217
207
|
className={`pointer-events-auto ${buttonPosition} bg-gray-700 text-white z-40 focus-yellow`}
|
|
218
208
|
style={getButtonStyle}
|
|
219
|
-
onClick={() =>
|
|
209
|
+
onClick={() => (isVisible ? closePanel() : openPanel())}
|
|
220
210
|
aria-expanded={isVisible}
|
|
221
211
|
aria-controls="sliding-panel"
|
|
222
212
|
>
|
|
@@ -225,9 +215,11 @@ export const SlidingPanel = ({
|
|
|
225
215
|
|
|
226
216
|
<div
|
|
227
217
|
ref={panelRef}
|
|
218
|
+
tabIndex={-1}
|
|
228
219
|
id="sliding-panel"
|
|
229
220
|
className={`${panelBase} ${panelLayout} pointer-events-auto`}
|
|
230
221
|
aria-hidden={!isVisible}
|
|
222
|
+
inert={!isVisible}
|
|
231
223
|
>
|
|
232
224
|
<div className="mt-4">{children}</div>
|
|
233
225
|
</div>
|
|
@@ -4,6 +4,8 @@ import { type KeyboardEvent, useCallback, useEffect, useState } from 'react';
|
|
|
4
4
|
|
|
5
5
|
import { LuArrowUp } from 'react-icons/lu';
|
|
6
6
|
|
|
7
|
+
import { KeyboardKeys } from '../../utils';
|
|
8
|
+
|
|
7
9
|
export type BackToTopProps = {
|
|
8
10
|
/** Scroll threshold in pixels before button appears */
|
|
9
11
|
threshold?: number;
|
|
@@ -87,7 +89,7 @@ export const BackToTop = ({
|
|
|
87
89
|
// Handle keyboard interaction
|
|
88
90
|
const handleKeyDown = useCallback(
|
|
89
91
|
(event: KeyboardEvent<HTMLButtonElement>) => {
|
|
90
|
-
if (event.key ===
|
|
92
|
+
if (event.key === KeyboardKeys.Enter || event.key === KeyboardKeys.Space) {
|
|
91
93
|
event.preventDefault();
|
|
92
94
|
scrollToTop();
|
|
93
95
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from 'react';
|
|
12
12
|
|
|
13
13
|
import type { ExtendProps } from '../../types/utils';
|
|
14
|
+
import { KeyboardKeys } from '../../utils';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* The current state of the menu
|
|
@@ -117,7 +118,11 @@ export const useDropdownMenu = (itemCount: number): DropdownMenuHook => {
|
|
|
117
118
|
useEffect(() => {
|
|
118
119
|
// handles exiting the menu when the `Escape` key is pressed
|
|
119
120
|
const escapeExitMenuHandler = (event: KeyboardEvent) => {
|
|
120
|
-
if (
|
|
121
|
+
if (
|
|
122
|
+
event.key === KeyboardKeys.Escape &&
|
|
123
|
+
document.activeElement &&
|
|
124
|
+
isMenuItem(document.activeElement)
|
|
125
|
+
) {
|
|
121
126
|
event.preventDefault();
|
|
122
127
|
|
|
123
128
|
setIsOpen(false);
|
|
@@ -166,14 +171,14 @@ export const useDropdownMenu = (itemCount: number): DropdownMenuHook => {
|
|
|
166
171
|
onPointerUp: () => setIsOpen(!isOpen),
|
|
167
172
|
onKeyDown: (event) => {
|
|
168
173
|
// space and enter act more like clicking and can toggle the menu open/closed
|
|
169
|
-
if (event.key ===
|
|
174
|
+
if (event.key === KeyboardKeys.Space || event.key === KeyboardKeys.Enter) {
|
|
170
175
|
event.preventDefault();
|
|
171
176
|
|
|
172
177
|
setIsOpen(!isOpen);
|
|
173
178
|
}
|
|
174
179
|
|
|
175
180
|
// the down arrow should open the menu
|
|
176
|
-
if (event.key ===
|
|
181
|
+
if (event.key === KeyboardKeys.ArrowDown) {
|
|
177
182
|
event.preventDefault();
|
|
178
183
|
|
|
179
184
|
if (!isOpen) {
|
|
@@ -208,7 +213,7 @@ export const useDropdownMenu = (itemCount: number): DropdownMenuHook => {
|
|
|
208
213
|
let focusIndex = currentFocusIndex.current;
|
|
209
214
|
|
|
210
215
|
// close the menu if you "select" an item
|
|
211
|
-
if (event.key ===
|
|
216
|
+
if (event.key === KeyboardKeys.Space || event.key === KeyboardKeys.Enter) {
|
|
212
217
|
event.preventDefault();
|
|
213
218
|
|
|
214
219
|
// we just called `preventDefault` so we need to manually click it otherwise nothing will happen
|
|
@@ -220,11 +225,11 @@ export const useDropdownMenu = (itemCount: number): DropdownMenuHook => {
|
|
|
220
225
|
|
|
221
226
|
if (focusIndex !== null) {
|
|
222
227
|
// arrow keys should cycle through each item in the menu
|
|
223
|
-
if (event.key ===
|
|
228
|
+
if (event.key === KeyboardKeys.ArrowDown) {
|
|
224
229
|
event.preventDefault();
|
|
225
230
|
|
|
226
231
|
focusIndex += 1;
|
|
227
|
-
} else if (event.key ===
|
|
232
|
+
} else if (event.key === KeyboardKeys.ArrowUp) {
|
|
228
233
|
event.preventDefault();
|
|
229
234
|
|
|
230
235
|
focusIndex -= 1;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { KeyboardKeys } from '../../utils';
|
|
3
4
|
import { Link } from '../link/Link';
|
|
4
5
|
|
|
5
6
|
export type SkipLinkProps = {
|
|
@@ -21,12 +22,12 @@ export const SkipLink = ({ mainContentId = 'main-content' }: SkipLinkProps) => {
|
|
|
21
22
|
return (
|
|
22
23
|
<nav aria-label="Skip navigation">
|
|
23
24
|
<Link
|
|
24
|
-
className="bg-focus focus:relative focus:top-0
|
|
25
|
-
visited:text-black hover:text-black
|
|
25
|
+
className="absolute w-full p-3 text-black bg-focus focus:relative focus:top-0 -top-full
|
|
26
|
+
visited:text-black hover:text-black skip-link"
|
|
26
27
|
href={`#${mainContentId}`}
|
|
27
28
|
onClick={handleActivate}
|
|
28
29
|
onKeyDown={(event) => {
|
|
29
|
-
if (event.key ===
|
|
30
|
+
if (event.key === KeyboardKeys.Enter || event.key === KeyboardKeys.Space) {
|
|
30
31
|
event.preventDefault(); // Prevent default scroll/jump
|
|
31
32
|
handleActivate();
|
|
32
33
|
}
|
package/src/errors/ApiError.ts
CHANGED
|
@@ -1,33 +1,95 @@
|
|
|
1
1
|
/* eslint-disable no-restricted-syntax */
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import z from 'zod/v4';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import { HttpStatus, HttpStatusText } from '../http';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Schema defining the JSON shape of error responses returned by a server. (The proxy is where
|
|
9
|
+
* the error is used the most, though it isn't unique to the proxy and may be used elsewhere).
|
|
10
|
+
*
|
|
11
|
+
* Note: This schema intentionally excludes the HTTP `status` field,
|
|
12
|
+
* as that value is carried in the HTTP response itself (via the status code)
|
|
13
|
+
* and in the in-memory {@link ApiError} class for internal use.
|
|
14
|
+
*/
|
|
15
|
+
export const ApiErrorSchema = z.object({
|
|
16
|
+
message: z.string(),
|
|
17
|
+
details: z.string().nullable(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export type ApiErrorSchemaOutput = z.output<typeof ApiErrorSchema>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Represents an error that can be returned by an API or proxy endpoint.
|
|
24
|
+
*
|
|
25
|
+
* Use this class to consistently capture errors along with an HTTP status
|
|
26
|
+
* code and optional detailed message. This allows:
|
|
27
|
+
* - Returning structured errors from API handlers or internal proxies.
|
|
28
|
+
* - Setting the status code for the response when an error has occurred.
|
|
29
|
+
* - Including additional context in `details` for debugging.
|
|
30
|
+
*
|
|
31
|
+
* Note:
|
|
32
|
+
* - Although it contains the same members, this class is separate from {@link ApiErrorSchema}.
|
|
33
|
+
* The schema defines the serialized JSON payload returned to clients, while this class
|
|
34
|
+
* *also* includes meta information of the response, such as the HTTP status code.
|
|
35
|
+
* The status code is typically only used internally inside the proxy (to forward status codes),
|
|
36
|
+
* but it could be accessed if a request 1 made with `throw` set to `false.`
|
|
37
|
+
*/
|
|
38
|
+
export class ApiError extends Error implements z.output<typeof ApiErrorSchema> {
|
|
39
|
+
public readonly details: string | null;
|
|
40
|
+
public readonly digest: string;
|
|
6
41
|
public readonly status: number;
|
|
7
|
-
|
|
8
|
-
|
|
42
|
+
// `true` if this class was reconstructed on the client from an response from the proxy
|
|
43
|
+
private readonly rehydrated: boolean;
|
|
9
44
|
|
|
10
|
-
constructor(
|
|
45
|
+
constructor(
|
|
46
|
+
message: string,
|
|
47
|
+
status: number,
|
|
48
|
+
details?: string | null,
|
|
49
|
+
options?: { rehydrated?: boolean },
|
|
50
|
+
) {
|
|
11
51
|
super(message);
|
|
52
|
+
|
|
12
53
|
this.name = 'ApiError';
|
|
13
54
|
this.status = status;
|
|
14
|
-
this.
|
|
15
|
-
this.
|
|
55
|
+
this.details = details ?? null;
|
|
56
|
+
this.digest = crypto.randomUUID();
|
|
57
|
+
this.rehydrated = options?.rehydrated ?? false;
|
|
16
58
|
|
|
17
59
|
// Maintains proper stack trace for where our error was thrown (only available on V8)
|
|
18
60
|
if (Error.captureStackTrace) {
|
|
19
61
|
Error.captureStackTrace(this, ApiError);
|
|
20
62
|
}
|
|
63
|
+
|
|
64
|
+
this.logError();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private logError() {
|
|
68
|
+
const trimmedStack = this.stack
|
|
69
|
+
?.split('\n')
|
|
70
|
+
// Filter out noisy frames but keep traces from toolkit for debugging
|
|
71
|
+
.filter((line) => !line.includes('node_modules') || line.includes('next-toolkit'))
|
|
72
|
+
.join('\n');
|
|
73
|
+
|
|
74
|
+
const prefix =
|
|
75
|
+
!this.rehydrated && typeof window !== 'undefined' ? 'Client API Error' : 'Server API Error';
|
|
76
|
+
|
|
77
|
+
console.error(
|
|
78
|
+
// eslint-disable-next-line sonarjs/no-nested-template-literals
|
|
79
|
+
`[${prefix}]: ${this.message}${this.details ? ` (${this.details})` : ''}. Digest: ${
|
|
80
|
+
this.digest
|
|
81
|
+
}\n${!this.rehydrated ? (trimmedStack ?? 'aa') : 'bb'}`,
|
|
82
|
+
);
|
|
21
83
|
}
|
|
22
84
|
|
|
23
85
|
// Helper method to check if it's a client error (4xx)
|
|
24
86
|
get isClientError(): boolean {
|
|
25
|
-
return this.status >=
|
|
87
|
+
return this.status >= HttpStatus.BadRequest && this.status < HttpStatus.InternalServerError;
|
|
26
88
|
}
|
|
27
89
|
|
|
28
90
|
// Helper method to check if it's a server error (5xx)
|
|
29
91
|
get isServerError(): boolean {
|
|
30
|
-
return this.status >=
|
|
92
|
+
return this.status >= HttpStatus.InternalServerError;
|
|
31
93
|
}
|
|
32
94
|
|
|
33
95
|
// Convert to a plain object for JSON serialization
|
|
@@ -36,36 +98,48 @@ export class ApiError extends Error {
|
|
|
36
98
|
name: this.name,
|
|
37
99
|
message: this.message,
|
|
38
100
|
status: this.status,
|
|
39
|
-
code: this.code,
|
|
40
101
|
details: this.details,
|
|
41
102
|
};
|
|
42
103
|
}
|
|
43
104
|
|
|
44
105
|
// Static factory methods for common error types
|
|
45
|
-
static badRequest(
|
|
46
|
-
return new ApiError(
|
|
106
|
+
static badRequest(details?: string): ApiError {
|
|
107
|
+
return new ApiError(HttpStatusText.BadRequest, HttpStatus.BadRequest, details);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
static notFound(details?: string): ApiError {
|
|
111
|
+
return new ApiError(HttpStatusText.NotFound, HttpStatus.NotFound, details);
|
|
47
112
|
}
|
|
48
113
|
|
|
49
|
-
static
|
|
50
|
-
return new ApiError(
|
|
114
|
+
static unauthorized(details?: string): ApiError {
|
|
115
|
+
return new ApiError(HttpStatusText.Unauthorized, HttpStatus.Unauthorized, details);
|
|
51
116
|
}
|
|
52
117
|
|
|
53
|
-
static
|
|
54
|
-
return new ApiError(
|
|
118
|
+
static notAllowed(details?: string): ApiError {
|
|
119
|
+
return new ApiError(HttpStatusText.NotAllowed, HttpStatus.NotAllowed, details);
|
|
55
120
|
}
|
|
56
121
|
|
|
57
|
-
static
|
|
58
|
-
return new ApiError(
|
|
122
|
+
static notImplemented(details?: string): ApiError {
|
|
123
|
+
return new ApiError(HttpStatusText.NotImplemented, HttpStatus.NotImplemented, details);
|
|
59
124
|
}
|
|
60
125
|
|
|
61
|
-
static
|
|
62
|
-
return new ApiError(
|
|
126
|
+
static forbidden(details?: string): ApiError {
|
|
127
|
+
return new ApiError(HttpStatusText.Forbidden, HttpStatus.Forbidden, details);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
static unprocessableContent(details?: string): ApiError {
|
|
131
|
+
return new ApiError(
|
|
132
|
+
HttpStatusText.UnprocessableContent,
|
|
133
|
+
HttpStatus.UnprocessableContent,
|
|
134
|
+
details,
|
|
135
|
+
);
|
|
63
136
|
}
|
|
64
137
|
|
|
65
|
-
static
|
|
138
|
+
static internalServerError(details?: string): ApiError {
|
|
66
139
|
return new ApiError(
|
|
67
|
-
|
|
68
|
-
|
|
140
|
+
HttpStatusText.InternalServerError,
|
|
141
|
+
HttpStatus.InternalServerError,
|
|
142
|
+
details,
|
|
69
143
|
);
|
|
70
144
|
}
|
|
71
145
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Use the {@link MimeTypes} type for a union type containing these mime types.
|
|
3
|
+
*/
|
|
4
|
+
export const MimeType = {
|
|
5
|
+
Json: 'application/json',
|
|
6
|
+
JsonLd: 'application/ld+json',
|
|
7
|
+
GeoJson: 'application/geo+json',
|
|
8
|
+
Png: 'image/png',
|
|
9
|
+
XJsonLines: 'application/x-jsonlines',
|
|
10
|
+
Csv: 'text/csv',
|
|
11
|
+
Form: 'application/x-www-form-urlencoded',
|
|
12
|
+
Text: 'text/plain',
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Use the {@link MimeType} object for named constants of each mime type.
|
|
17
|
+
*/
|
|
18
|
+
export type MimeTypes = (typeof MimeType)[keyof typeof MimeType];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns `true` if the mime type is a non-streamed JSON mime type (I.E. not JSON X-Lines).
|
|
22
|
+
*/
|
|
23
|
+
export const isJsonMimeType = (mime: MimeTypes | string | null): boolean => {
|
|
24
|
+
return ([MimeType.Json, MimeType.GeoJson, MimeType.JsonLd] as string[]).includes(mime ?? '');
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns `true` if the mime type is a plain-text mime type.
|
|
29
|
+
*/
|
|
30
|
+
export const isTextMimeType = (mime: MimeTypes | string | null): boolean => {
|
|
31
|
+
return ([MimeType.Text, MimeType.Csv] as string[]).includes(mime ?? '');
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Use the {@link HttpStatuses} type for a union type containing these status codes.
|
|
36
|
+
*
|
|
37
|
+
* Use the {@link HttpStatusText} object for the status text represented by each code.
|
|
38
|
+
*/
|
|
39
|
+
export const HttpStatus = {
|
|
40
|
+
Ok: 200,
|
|
41
|
+
Created: 201,
|
|
42
|
+
NoContent: 204,
|
|
43
|
+
BadRequest: 400,
|
|
44
|
+
Unauthorized: 401,
|
|
45
|
+
Forbidden: 403,
|
|
46
|
+
NotFound: 404,
|
|
47
|
+
NotAllowed: 405,
|
|
48
|
+
UnprocessableContent: 422,
|
|
49
|
+
InternalServerError: 500,
|
|
50
|
+
NotImplemented: 501,
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Use the {@link HttpStatus} object for named constants of each status code.
|
|
55
|
+
*/
|
|
56
|
+
export type HttpStatuses = (typeof HttpStatus)[keyof typeof HttpStatus];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Status texts for each status code.
|
|
60
|
+
*/
|
|
61
|
+
export const HttpStatusText = {
|
|
62
|
+
Ok: 'OK',
|
|
63
|
+
Created: 'Created',
|
|
64
|
+
NoContent: 'No Content',
|
|
65
|
+
BadRequest: 'Bad Request',
|
|
66
|
+
Unauthorized: 'Unauthorized',
|
|
67
|
+
Forbidden: 'Forbidden',
|
|
68
|
+
NotFound: 'Not Found',
|
|
69
|
+
NotAllowed: 'Method Not Allowed',
|
|
70
|
+
UnprocessableContent: 'Unprocessable Content',
|
|
71
|
+
InternalServerError: 'Internal Server Error',
|
|
72
|
+
NotImplemented: 'Not Implemented',
|
|
73
|
+
} as const;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Use the {@link HttpMethods} type for a union type containing these method verbs.
|
|
77
|
+
*/
|
|
78
|
+
export const HttpMethod = {
|
|
79
|
+
Get: 'get',
|
|
80
|
+
Post: 'post',
|
|
81
|
+
Put: 'put',
|
|
82
|
+
Patch: 'patch',
|
|
83
|
+
Delete: 'delete',
|
|
84
|
+
} as const;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Use the {@link HttpMethod} object for named constants of each method verb.
|
|
88
|
+
*/
|
|
89
|
+
export type HttpMethods = (typeof HttpMethod)[keyof typeof HttpMethod];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Use the {@link Headers} type for a union type containing these headers.
|
|
93
|
+
*/
|
|
94
|
+
export const Header = {
|
|
95
|
+
ContentType: 'Content-Type',
|
|
96
|
+
ContentDisposition: 'Content-Disposition',
|
|
97
|
+
Accept: 'Accept',
|
|
98
|
+
AcceptCrs: 'Accept-Crs',
|
|
99
|
+
CsvHeader: 'CSV-Header',
|
|
100
|
+
XTotalItems: 'X-Total-Items',
|
|
101
|
+
XRequestId: 'X-Request-ID',
|
|
102
|
+
} as const;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Use the {@link Header} object for named constants of each header.
|
|
106
|
+
*/
|
|
107
|
+
export type Headers = (typeof Header)[keyof typeof Header];
|
|
108
|
+
|
|
109
|
+
// Special types used for downloading files via a `POST` request
|
|
110
|
+
export const SPECIAL_FORM_DATA_TYPE = '_type';
|
|
111
|
+
export const SPECIAL_FORM_DOWNLOAD_POST = 'file-download';
|