@saasicat/ui-vue 0.24.2 → 0.26.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 +32 -8
- package/dist/{catalog-DJcfm5N2.d.cts → catalog-Dch1Ryw0.d.cts} +353 -69
- package/dist/{catalog-DJcfm5N2.d.ts → catalog-Dch1Ryw0.d.ts} +353 -69
- package/dist/{chunk-F7NRM6KI.js → chunk-BPF2BMCQ.js} +748 -24
- package/dist/{chunk-D74FFCCP.js → chunk-NEXTZZRQ.js} +95 -177
- package/dist/{chunk-TQQMKWN6.js → chunk-O3J3ITF2.js} +119 -7
- package/dist/client/index.cjs +758 -83
- package/dist/client/index.d.cts +414 -9
- package/dist/client/index.d.ts +414 -9
- package/dist/client/index.js +84 -12
- package/dist/index.cjs +1325 -294
- package/dist/index.d.cts +205 -15
- package/dist/index.d.ts +205 -15
- package/dist/index.js +566 -240
- package/dist/quasar/index.cjs +574 -62
- package/dist/quasar/index.d.cts +61 -6
- package/dist/quasar/index.d.ts +61 -6
- package/dist/quasar/index.js +83 -19
- package/dist/{use-sa-theme-OUBlaqgl.d.ts → resource-registry-D7Xjs-5f.d.cts} +157 -7
- package/dist/{use-sa-theme-B5t7oXph.d.cts → resource-registry-TaR7rtq0.d.ts} +157 -7
- package/package.json +4 -3
- package/src/client/admin-error.ts +552 -0
- package/src/client/admin-resource-client.ts +9 -12
- package/src/client/batch-column-fetcher.ts +2 -2
- package/src/client/boot-loader.ts +12 -1
- package/src/client/http/axios-http-client.ts +383 -0
- package/src/client/http/fetch-http-client.ts +89 -0
- package/src/client/http/index.ts +6 -0
- package/src/client/http-json.ts +82 -16
- package/src/client/i18n/messages/discovery.ts +0 -6
- package/src/client/i18n/messages/errors.ts +44 -0
- package/src/client/i18n/messages/marketing.ts +0 -6
- package/src/client/i18n/messages/promos.ts +0 -2
- package/src/client/i18n/messages/shell.ts +12 -0
- package/src/client/i18n/messages.ts +3 -0
- package/src/client/index.ts +6 -0
- package/src/client/manifest-loader.ts +65 -15
- package/src/client/resources/audit.resource.ts +25 -0
- package/src/client/resources/define-resource.ts +166 -0
- package/src/client/resources/index.ts +42 -0
- package/src/client/resources/list-resource.ts +176 -0
- package/src/client/resources/plans.resource.ts +169 -0
- package/src/client/resources/resource-request.ts +117 -0
- package/src/client/resources/tenants.resource.ts +27 -0
- package/src/client/types.ts +10 -3
- package/src/components/BundleVersionPublishDialog.vue +1 -1
- package/src/components/MarketingPromotionsTab.vue +263 -241
- package/src/components/ThemeSwitcher.vue +85 -0
- package/src/components/admin-page/AdminAccordion.vue +125 -0
- package/src/components/bundle-editor/BundleCreatePanel.vue +1 -1
- package/src/components/dialogs/PromoCodeDialogFields.vue +20 -20
- package/src/components/plan/PlanCycleToggle.vue +1 -1
- package/src/components/plan-detail/PlanVersionsPanel.vue +5 -2
- package/src/index.ts +5 -0
- package/src/pages-standard/AdminLayout.vue +57 -0
- package/src/pages-standard/AdminManifestErrorPage.vue +4 -3
- package/src/pages-standard/BundlesPage.vue +5 -35
- package/src/pages-standard/DiscoveryPage.vue +8 -1
- package/src/pages-standard/MarketingCatalogPage.vue +14 -3
- package/src/pages-standard/SuperAdminLoginPage.vue +23 -2
- package/src/pages-standard/SuperAdminSetupWizard.vue +37 -3
- package/src/pages-standard/bundles-page/BundleAccordionList.vue +30 -32
- package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +80 -88
- package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +73 -79
- package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +44 -6
- package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +8 -2
- package/src/pages-tenant/PackageSnapshotPanel.vue +31 -2
- package/src/quasar/confirm.ts +83 -0
- package/src/quasar/create-super-admin-app.ts +84 -10
- package/src/quasar/dark-bridge.ts +47 -10
- package/src/quasar/index.ts +1 -0
- package/src/ui/theme/components/accordion.css +118 -0
- package/src/ui/theme/index.css +1 -0
- package/src/vue/create-admin-routes.ts +9 -1
- package/src/vue/platform-loaders.ts +12 -2
- package/src/vue/resource-registry.ts +211 -0
- package/src/vue/super-admin-context.ts +20 -5
- package/src/vue/ui-confirm.ts +76 -0
- package/src/vue/use-api-list.ts +50 -45
- package/src/vue/use-async-action.ts +170 -0
- package/src/vue/use-async-data.ts +83 -0
- package/src/vue/use-bundles.ts +34 -7
- package/src/vue/use-catalog-entries.ts +37 -12
- package/src/vue/use-discovery.ts +27 -6
- package/src/vue/use-marketing-projections.ts +29 -7
- package/src/vue/use-plans.ts +36 -8
- package/src/vue/use-promotions.ts +27 -7
- package/src/vue/use-resource-list.ts +272 -0
- package/src/vue/use-sa-theme.ts +34 -5
- package/src/vue/use-tenant-subscription-bundles.ts +22 -3
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// The axios implementation of `HttpClient` — without depending on axios.
|
|
2
|
+
//
|
|
3
|
+
// Six near-identical copies of this function exist across the known consumer
|
|
4
|
+
// apps and the documentation: the notesapp admin and web apps, the scaffolder
|
|
5
|
+
// template, the handbook, vereinsfux and autohauspro. They differ in the
|
|
6
|
+
// prefix they strip and in nothing else that matters.
|
|
7
|
+
//
|
|
8
|
+
// `AxiosLike` is structural on purpose. A `dependencies` entry would make every
|
|
9
|
+
// consumer install axios to use a package that speaks `fetch` by default, and a
|
|
10
|
+
// `peerDependencies` entry would do the same with a warning instead of an
|
|
11
|
+
// install. The one method this needs is `request`, and any axios instance has
|
|
12
|
+
// it.
|
|
13
|
+
//
|
|
14
|
+
// The tests do install axios, as a devDependency, because the one thing a
|
|
15
|
+
// stand-in cannot reproduce is the transform axios applies to a body — and that
|
|
16
|
+
// transform is what `bodyIsRaw` below has to read correctly.
|
|
17
|
+
|
|
18
|
+
import { isAxiosNoResponseError, markTransportFailure } from '../admin-error.js';
|
|
19
|
+
import { trimTrailingSlashes } from '../http-json.js';
|
|
20
|
+
import type { HttpClient, HttpResponse } from '../types.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The part of the merged request config axios echoes back on every response —
|
|
24
|
+
* on the one it resolves with and on the one it attaches to a rejection. These
|
|
25
|
+
* three fields say whether `data` is a decoded value or the body as it arrived,
|
|
26
|
+
* for as long as axios's own response transform is the one that produced it;
|
|
27
|
+
* see `bodyIsRaw`.
|
|
28
|
+
*
|
|
29
|
+
* They are `unknown` rather than their axios types so that a real
|
|
30
|
+
* `AxiosResponse` stays structurally assignable without this package taking a
|
|
31
|
+
* dependency on axios to name them.
|
|
32
|
+
*/
|
|
33
|
+
export interface AxiosLikeResponseConfig {
|
|
34
|
+
transformResponse?: unknown;
|
|
35
|
+
responseType?: unknown;
|
|
36
|
+
transitional?: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AxiosLikeResponse {
|
|
40
|
+
status: number;
|
|
41
|
+
data: unknown;
|
|
42
|
+
headers: unknown;
|
|
43
|
+
config?: AxiosLikeResponseConfig;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AxiosLikeConfig {
|
|
47
|
+
url: string;
|
|
48
|
+
method?: string;
|
|
49
|
+
headers?: Record<string, string>;
|
|
50
|
+
data?: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The part of an axios instance this adapter uses. */
|
|
54
|
+
export interface AxiosLike {
|
|
55
|
+
request(config: AxiosLikeConfig): Promise<AxiosLikeResponse>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What an instance leaves in `response.data`, for the one case the response
|
|
60
|
+
* cannot describe: an instance that replaced `transformResponse` with a
|
|
61
|
+
* non-empty pipeline of its own.
|
|
62
|
+
*
|
|
63
|
+
* - `'auto'` — read it off the config axios echoes on the response. Correct for
|
|
64
|
+
* an instance that still runs axios's own transform, and for every way of
|
|
65
|
+
* switching that transform's parsing off: `responseType: 'text'`,
|
|
66
|
+
* `transitional: { forcedJSONParsing: false }`, and a `transformResponse`
|
|
67
|
+
* that runs nothing (`[]` or `null`).
|
|
68
|
+
* - `'raw'` — `data` is the body as it arrived, and `json()` parses it.
|
|
69
|
+
* - `'decoded'` — `data` is a value the pipeline already produced, and `json()`
|
|
70
|
+
* hands it over untouched.
|
|
71
|
+
*/
|
|
72
|
+
export type AxiosResponseBody = 'auto' | 'raw' | 'decoded';
|
|
73
|
+
|
|
74
|
+
export interface AxiosHttpClientOptions {
|
|
75
|
+
/**
|
|
76
|
+
* Prefix(es) to remove from the start of a URL before handing it to the
|
|
77
|
+
* instance, for the usual case where the instance already carries them as
|
|
78
|
+
* its `baseURL`. Tried in order, first match wins — so list the longest
|
|
79
|
+
* first (`['/api/v1', '/api']`), or `/api/v1/admin/x` loses only `/api`.
|
|
80
|
+
*/
|
|
81
|
+
stripPrefix?: string | readonly string[];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* How this instance hands the body over. Defaults to `'auto'`, which needs
|
|
85
|
+
* no configuration and is right for every instance that leaves axios's own
|
|
86
|
+
* `transformResponse` in place.
|
|
87
|
+
*
|
|
88
|
+
* Set it only if you replaced `transformResponse` with a non-empty pipeline
|
|
89
|
+
* of your own: `'raw'` if that pipeline hands the body over as it arrived
|
|
90
|
+
* (`[(data) => data]`), `'decoded'` if it parses. The response cannot be
|
|
91
|
+
* read for the answer — both echo one opaque function — so `'auto'` would
|
|
92
|
+
* take your pipeline for axios's; see `bodyIsRaw`.
|
|
93
|
+
*
|
|
94
|
+
* The declaration is read before anything else, an empty `data` included:
|
|
95
|
+
* under `'decoded'` an empty `data` is the empty string the pipeline
|
|
96
|
+
* produced, and `json()` hands it over; under `'raw'` it is an empty body,
|
|
97
|
+
* and `json()` throws the way `Response.json()` does.
|
|
98
|
+
*/
|
|
99
|
+
responseBody?: AxiosResponseBody;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Removes a prefix only at a path boundary. `startsWith` alone is not enough:
|
|
104
|
+
* `/api/v10/x` starts with `/api/v1` and would be cut to `0/x`, which is the
|
|
105
|
+
* shape of the `url.slice(7)` the copies of this function all used.
|
|
106
|
+
*
|
|
107
|
+
* The prefix is trimmed of trailing slashes first. Writing the option the way
|
|
108
|
+
* an axios `baseURL` is usually written — `'/api/v1/'` — otherwise left the
|
|
109
|
+
* remainder as `admin/boot`, which does not start with `/`, so the boundary
|
|
110
|
+
* check rejected it and the URL passed through unstripped. axios then prepended
|
|
111
|
+
* its base and sent `/api/v1/api/v1/admin/boot`: every request broken, for a
|
|
112
|
+
* configuration that looks correct.
|
|
113
|
+
*/
|
|
114
|
+
function stripped(url: string, prefixes: readonly string[]): string {
|
|
115
|
+
for (const prefix of prefixes) {
|
|
116
|
+
const trimmed = trimTrailingSlashes(prefix);
|
|
117
|
+
if (!trimmed || !url.startsWith(trimmed)) continue;
|
|
118
|
+
const rest = url.slice(trimmed.length);
|
|
119
|
+
if (rest === '') return '/';
|
|
120
|
+
if (rest.startsWith('/')) return rest;
|
|
121
|
+
// A query or a fragment ends the path just as a slash does, so
|
|
122
|
+
// `/api/v1?tenant=acme` is the prefix too — without this it fell
|
|
123
|
+
// through unstripped and was sent doubled.
|
|
124
|
+
if (rest.startsWith('?') || rest.startsWith('#')) return `/${rest}`;
|
|
125
|
+
}
|
|
126
|
+
return url;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Reads a header without knowing how the instance spells it.
|
|
131
|
+
*
|
|
132
|
+
* Both casings occur on the reading side too: the manifest loader asks for
|
|
133
|
+
* `ETag` and then for `etag`, because it could not rely on the shims agreeing.
|
|
134
|
+
* Answering both is what makes that second attempt unnecessary.
|
|
135
|
+
*/
|
|
136
|
+
function headerReader(headers: unknown): (name: string) => string | null {
|
|
137
|
+
const record =
|
|
138
|
+
typeof headers === 'object' && headers !== null ? (headers as Record<string, unknown>) : {};
|
|
139
|
+
const lowered = new Map<string, unknown>();
|
|
140
|
+
for (const [key, value] of Object.entries(record)) lowered.set(key.toLowerCase(), value);
|
|
141
|
+
return (name) => {
|
|
142
|
+
const value = lowered.get(name.toLowerCase());
|
|
143
|
+
return value == null ? null : String(value);
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Whether `response.data` is the body as it arrived, rather than a value axios
|
|
149
|
+
* already decoded out of it.
|
|
150
|
+
*
|
|
151
|
+
* The type of `data` cannot answer that. Under axios's default transform a
|
|
152
|
+
* body of `"ready"` decodes to the string `ready`, and a body that failed to
|
|
153
|
+
* parse is handed back as the string it was — both arrive as strings, both
|
|
154
|
+
* under an `application/json` content type, so neither the value nor the media
|
|
155
|
+
* type separates them. Decoding a second time then throws on `"ready"` and,
|
|
156
|
+
* worse, silently turns the string `"null"` into `null`.
|
|
157
|
+
*
|
|
158
|
+
* `'auto'` reads the answer off the merged config axios echoes on every
|
|
159
|
+
* response, including the one carried by a rejection. Two readings, in order: a
|
|
160
|
+
* `transformResponse` axios's own iteration would run nothing for is proof that
|
|
161
|
+
* nothing touched the body; otherwise the condition axios's own default
|
|
162
|
+
* transform parses under, negated, off `responseType` and `transitional`. An
|
|
163
|
+
* empty `data` is answered before either of them, and the comment on that line
|
|
164
|
+
* says what it costs.
|
|
165
|
+
*
|
|
166
|
+
* **The second reading is an assumption, not a measurement**: it holds while
|
|
167
|
+
* axios's default transform is the one that ran. `config.transformResponse` is
|
|
168
|
+
* not a description of the decoding — it is the function array axios applied,
|
|
169
|
+
* and functions are opaque. An instance built with `[(data) => data]` and one
|
|
170
|
+
* built with `[(data) => JSON.parse(data)]` echo the same config as a stock
|
|
171
|
+
* instance does, down to the array length and the arity of its one element, and
|
|
172
|
+
* each can be handed a body that makes it produce the very same `data` as the
|
|
173
|
+
* others — `"ready"` through the first, `"\"ready\""` through the other two,
|
|
174
|
+
* all three arriving as `"ready"` — while needing the opposite answer.
|
|
175
|
+
* Nothing else on the response separates them either: the
|
|
176
|
+
* only field that differs is `content-length`, which gzip or a chunked reply
|
|
177
|
+
* makes meaningless. Guessing "non-empty means decoded" is silently wrong for
|
|
178
|
+
* the first, guessing "non-empty means raw" for the second.
|
|
179
|
+
*
|
|
180
|
+
* So an instance that replaced `transformResponse` says which it is, through
|
|
181
|
+
* the `responseBody` option, and `'auto'` covers everyone who did not. The
|
|
182
|
+
* declaration is read first, because a reading that overrode it would leave the
|
|
183
|
+
* tie it exists to break unbroken.
|
|
184
|
+
*
|
|
185
|
+
* A response carrying no config at all is read as decoded: real axios always
|
|
186
|
+
* attaches one, so only a stand-in can omit it, and reading a stand-in as
|
|
187
|
+
* already-decoded fails loudly — objects arriving as strings — instead of
|
|
188
|
+
* silently changing what a scalar means.
|
|
189
|
+
*/
|
|
190
|
+
function bodyIsRaw(response: AxiosLikeResponse, declared: AxiosResponseBody): boolean {
|
|
191
|
+
// The declaration answers for every body, the empty one included. Under
|
|
192
|
+
// `'decoded'` `data` is the value the pipeline produced and `''` is the
|
|
193
|
+
// empty string; under `'raw'` `data` is the body and an empty one is what
|
|
194
|
+
// `Response.json()` throws on.
|
|
195
|
+
if (declared !== 'auto') return declared === 'raw';
|
|
196
|
+
|
|
197
|
+
// Under `'auto'` an empty `data` is read as no body, and `json()` therefore
|
|
198
|
+
// throws as `Response.json()` does. For an instance that hands the body
|
|
199
|
+
// over that reading is exact — nothing shortens a body to nothing. For one
|
|
200
|
+
// that decodes it is a collision the response cannot resolve: axios's
|
|
201
|
+
// transform turns a zero-byte body and the two bytes `""` into the same
|
|
202
|
+
// `''`, and `""` is valid JSON meaning the empty string. That is the same
|
|
203
|
+
// undecidability the docstring above owns, so it has the same way out —
|
|
204
|
+
// `responseBody: 'decoded'`, which is why it is read one line further up.
|
|
205
|
+
if (response.data === '') return true;
|
|
206
|
+
|
|
207
|
+
// `config.transformResponse` is not a description of the decoding, but it
|
|
208
|
+
// is the collection axios handed to its own `forEach`, and two of the
|
|
209
|
+
// shapes that accepts run nothing at all: the empty array, and `null`
|
|
210
|
+
// (`forEach` returns immediately for a nullish collection). Either is
|
|
211
|
+
// therefore proof that the body was not touched.
|
|
212
|
+
//
|
|
213
|
+
// `=== null` and not `== null`: an *absent* `transformResponse` reads as
|
|
214
|
+
// `undefined` too, and absence is not a statement about decoding — it is
|
|
215
|
+
// what a structural stand-in produces, which the readings below already
|
|
216
|
+
// answer for. Only a config that spells the property out has said anything.
|
|
217
|
+
const config = response.config;
|
|
218
|
+
if (config?.transformResponse === null) return true;
|
|
219
|
+
if (Array.isArray(config?.transformResponse) && config.transformResponse.length === 0) {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// What follows is the negation of the one condition axios's default
|
|
224
|
+
// transform parses under (`lib/defaults/index.js`):
|
|
225
|
+
//
|
|
226
|
+
// (forcedJSONParsing && !responseType) || responseType === 'json'
|
|
227
|
+
//
|
|
228
|
+
// Written from that condition rather than from the cases it produces:
|
|
229
|
+
// several settings turn the parsing off, and keeping a list of them is how
|
|
230
|
+
// one of them gets forgotten.
|
|
231
|
+
const responseType = config?.responseType;
|
|
232
|
+
if (responseType === 'json') return false;
|
|
233
|
+
if (typeof responseType === 'string' && responseType !== '') return true;
|
|
234
|
+
const transitional = config?.transitional;
|
|
235
|
+
return (
|
|
236
|
+
typeof transitional === 'object' &&
|
|
237
|
+
transitional !== null &&
|
|
238
|
+
(transitional as { forcedJSONParsing?: unknown }).forcedJSONParsing === false
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Reads a body axios was told not to turn into text.
|
|
244
|
+
*
|
|
245
|
+
* `responseType: 'arraybuffer'` and `'blob'` switch the decoding off the way
|
|
246
|
+
* `'text'` does, so the body arrives exactly as it was sent — only as bytes
|
|
247
|
+
* rather than as a string. Turning them back into text is a measurement, not a
|
|
248
|
+
* guess: JSON is defined in UTF-8, and `bodyIsRaw` already answers `true` for
|
|
249
|
+
* both, so the readings below stay the ones the rest of this file reasons about.
|
|
250
|
+
*
|
|
251
|
+
* A stream is refused. It can be consumed once, not synchronously, and not
|
|
252
|
+
* twice by `json()` and `text()` in turn; handing it back would satisfy the
|
|
253
|
+
* return type while breaking what the type promises, which is the failure mode
|
|
254
|
+
* this file spent three rounds removing. The message names the option that
|
|
255
|
+
* makes the request work.
|
|
256
|
+
*
|
|
257
|
+
* Anything else is passed through: a consumer's own transform may return an
|
|
258
|
+
* object, and that object is the body.
|
|
259
|
+
*/
|
|
260
|
+
async function readBody(data: unknown): Promise<unknown> {
|
|
261
|
+
if (typeof data === 'string' || data === null || data === undefined) return data;
|
|
262
|
+
if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
|
|
263
|
+
if (ArrayBuffer.isView(data)) return new TextDecoder().decode(data as Uint8Array);
|
|
264
|
+
const maybe = data as { text?: unknown; pipe?: unknown; getReader?: unknown };
|
|
265
|
+
if (typeof maybe.pipe === 'function' || typeof maybe.getReader === 'function') {
|
|
266
|
+
throw new TypeError(
|
|
267
|
+
"createAxiosHttpClient cannot read a streamed body: responseType 'stream' can be " +
|
|
268
|
+
"consumed only once, and never synchronously. Use 'arraybuffer' or the default " +
|
|
269
|
+
'for the instance the platform receives.',
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
if (typeof maybe.text === 'function') return (maybe.text as () => Promise<string>).call(data);
|
|
273
|
+
return data;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Presents an axios response as the `HttpResponse` the contract declares. */
|
|
277
|
+
function adapt(response: AxiosLikeResponse, declared: AxiosResponseBody): HttpResponse {
|
|
278
|
+
return {
|
|
279
|
+
status: response.status,
|
|
280
|
+
headers: { get: headerReader(response.headers) },
|
|
281
|
+
json: async () => {
|
|
282
|
+
const data = await readBody(response.data);
|
|
283
|
+
if (typeof data !== 'string') return data;
|
|
284
|
+
return bodyIsRaw({ ...response, data }, declared) ? JSON.parse(data) : data;
|
|
285
|
+
},
|
|
286
|
+
text: async () => {
|
|
287
|
+
const data = await readBody(response.data);
|
|
288
|
+
return typeof data === 'string' ? data : JSON.stringify(data);
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Adapts an axios instance to `HttpClient`.
|
|
295
|
+
*
|
|
296
|
+
* Two decisions are worth knowing about, because five of the six shims this
|
|
297
|
+
* replaces made them differently:
|
|
298
|
+
*
|
|
299
|
+
* **No status throws — but the instance still decides.** A 402, a 404 and a
|
|
300
|
+
* 500 all arrive as responses, because the platform reads statuses itself (a
|
|
301
|
+
* 304 is a cache hit, a 402 carries a limit payload) and can only do that for
|
|
302
|
+
* statuses it is handed. That is achieved by adapting the rejection rather
|
|
303
|
+
* than by overriding `validateStatus`: an override would make axios resolve
|
|
304
|
+
* everything, and a config that never rejects makes the **rejection half of
|
|
305
|
+
* the instance's own response interceptors unreachable** — the conventional
|
|
306
|
+
* place a consumer puts token refresh and retry. Their session would expire
|
|
307
|
+
* silently instead of refreshing.
|
|
308
|
+
*
|
|
309
|
+
* **`json()` decodes only what axios left undecoded.** Which of the two it is
|
|
310
|
+
* cannot be guessed from the value. The response says it for every instance
|
|
311
|
+
* that still runs axios's own `transformResponse`; an instance that replaced it
|
|
312
|
+
* says it with the `responseBody` option, because at that point the response no
|
|
313
|
+
* longer can. See `bodyIsRaw`.
|
|
314
|
+
*
|
|
315
|
+
* Nothing here overrides the instance's own configuration — not
|
|
316
|
+
* `validateStatus`, and not `transformResponse` either. Forcing the transform
|
|
317
|
+
* off would make the answer knowable, at the price of handing the consumer's
|
|
318
|
+
* own response interceptors a string where they had an object.
|
|
319
|
+
*/
|
|
320
|
+
export function createAxiosHttpClient(
|
|
321
|
+
instance: AxiosLike,
|
|
322
|
+
options: AxiosHttpClientOptions = {},
|
|
323
|
+
): HttpClient {
|
|
324
|
+
const prefixes =
|
|
325
|
+
typeof options.stripPrefix === 'string'
|
|
326
|
+
? [options.stripPrefix]
|
|
327
|
+
: (options.stripPrefix ?? []);
|
|
328
|
+
const responseBody = options.responseBody ?? 'auto';
|
|
329
|
+
|
|
330
|
+
return async (url, init) => {
|
|
331
|
+
try {
|
|
332
|
+
return adapt(
|
|
333
|
+
await instance.request({
|
|
334
|
+
url: stripped(url, prefixes),
|
|
335
|
+
method: (init?.method ?? 'GET').toUpperCase(),
|
|
336
|
+
headers: init?.headers,
|
|
337
|
+
data: init?.body,
|
|
338
|
+
}),
|
|
339
|
+
responseBody,
|
|
340
|
+
);
|
|
341
|
+
} catch (err: unknown) {
|
|
342
|
+
// A status the instance rejected still carries its response, and
|
|
343
|
+
// by then every interceptor has had its turn — including a refresh
|
|
344
|
+
// that may have retried and succeeded. Adapt what came back
|
|
345
|
+
// instead of rethrowing, so the seam keeps its one error shape.
|
|
346
|
+
const response = (err as { response?: AxiosLikeResponse })?.response;
|
|
347
|
+
if (response && typeof response.status === 'number') {
|
|
348
|
+
return adapt(response, responseBody);
|
|
349
|
+
}
|
|
350
|
+
// No response came back through this seam — and that is the whole
|
|
351
|
+
// of what this seam knows. It knows a request was attempted; it
|
|
352
|
+
// does not know whether one arrived, because an interceptor that
|
|
353
|
+
// answered a status with an error of its own has already replaced
|
|
354
|
+
// everything the rejection would have said. So the brand goes on
|
|
355
|
+
// exactly what axios itself reports as "made, nothing came back",
|
|
356
|
+
// which `isAxiosNoResponseError` reads off axios's documented
|
|
357
|
+
// `request`-without-`response` form.
|
|
358
|
+
//
|
|
359
|
+
// Measured with axios 1.18.1 against a real `node:http` server.
|
|
360
|
+
// Marked: connection refused and reset, DNS failure, timeout,
|
|
361
|
+
// abort — each of them also when a rejection interceptor rethrows
|
|
362
|
+
// it. Not marked: an interceptor answering a 401 with
|
|
363
|
+
// `new Error('session expired')`, whether or not it copies
|
|
364
|
+
// `error.config` or `error.request` across; the setup failures
|
|
365
|
+
// axios files under neither half (unsupported protocol, a signal
|
|
366
|
+
// aborted before the call, a throwing `paramsSerializer`); and any
|
|
367
|
+
// structural `AxiosLike` that is not axios at all.
|
|
368
|
+
//
|
|
369
|
+
// That last group is the decision rather than the measurement, and
|
|
370
|
+
// it is the case an earlier round asked to mark: an offline request
|
|
371
|
+
// through a client that says nothing about itself. It stays
|
|
372
|
+
// unmarked because nothing in it can be told from an interceptor's
|
|
373
|
+
// replacement error — `new Error('cannot reach api')` after a dead
|
|
374
|
+
// socket and `new Error('session expired')` after a 401 are the
|
|
375
|
+
// same object down to the last own property, so marking the first
|
|
376
|
+
// costs the second its message, which is the only explanation the
|
|
377
|
+
// operator would have had. A client that knows it could not send
|
|
378
|
+
// says so itself: `markTransportFailure` is exported for that, and
|
|
379
|
+
// `createFetchHttpClient` is the reference use.
|
|
380
|
+
throw isAxiosNoResponseError(err) ? markTransportFailure(err) : err;
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The `fetch` implementation of `HttpClient`, and the only place in the
|
|
2
|
+
// package that is allowed to call `fetch` (ESLint enforces that; see the
|
|
3
|
+
// `no-restricted-globals` block in the root config).
|
|
4
|
+
//
|
|
5
|
+
// It exists because every app was writing it. Two of the shims in the known
|
|
6
|
+
// consumer apps are this file with different constants in it: prepend a base
|
|
7
|
+
// URL, merge in the auth header, ask for JSON, hand back the response in the
|
|
8
|
+
// four fields `HttpResponse` declares.
|
|
9
|
+
|
|
10
|
+
import { markTransportFailure } from '../admin-error.js';
|
|
11
|
+
import { trimTrailingSlashes } from '../http-json.js';
|
|
12
|
+
import type { HttpClient, HttpResponse } from '../types.js';
|
|
13
|
+
|
|
14
|
+
export interface FetchHttpClientOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Prefix for relative URLs, e.g. `'https://api.example.com'`. Absolute
|
|
17
|
+
* URLs are passed through untouched.
|
|
18
|
+
*
|
|
19
|
+
* Note that an app configures its API prefix twice if it also passes a
|
|
20
|
+
* fully-qualified `apiBase` to the shell — the two would concatenate. Pick
|
|
21
|
+
* one; a doubled prefix 404s on the first request rather than degrading
|
|
22
|
+
* quietly, which is the reason this does not try to detect it.
|
|
23
|
+
*/
|
|
24
|
+
baseUrl?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Headers to add to every request, read per request so a token that
|
|
27
|
+
* changes between calls is picked up without rebuilding the client. May
|
|
28
|
+
* return a promise for a token that has to be refreshed first.
|
|
29
|
+
*
|
|
30
|
+
* Headers passed by the caller win, so a request that sets its own
|
|
31
|
+
* `Content-Type` keeps it.
|
|
32
|
+
*/
|
|
33
|
+
headers?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:|^\/\//i;
|
|
37
|
+
|
|
38
|
+
function resolveUrl(url: string, baseUrl: string | undefined): string {
|
|
39
|
+
if (!baseUrl || ABSOLUTE_URL.test(url)) return url;
|
|
40
|
+
return `${trimTrailingSlashes(baseUrl)}${url.startsWith('/') ? '' : '/'}${url}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Builds an `HttpClient` over `fetch`.
|
|
45
|
+
*
|
|
46
|
+
* With no options it is a bare passthrough plus an `Accept: application/json`
|
|
47
|
+
* header — the platform speaks JSON everywhere, and saying so is what stops a
|
|
48
|
+
* content-negotiating gateway from answering with HTML.
|
|
49
|
+
*/
|
|
50
|
+
export function createFetchHttpClient(options: FetchHttpClientOptions = {}): HttpClient {
|
|
51
|
+
return async (url, init) => {
|
|
52
|
+
const headers = new Headers(await options.headers?.());
|
|
53
|
+
// Only when the hook did not ask for something else. An app requesting
|
|
54
|
+
// a versioned or vendor representation —
|
|
55
|
+
// `Accept: application/vnd.example.v2+json` — has no other way to say
|
|
56
|
+
// so, because the per-call headers belong to platform code.
|
|
57
|
+
if (!headers.has('Accept')) headers.set('Accept', 'application/json');
|
|
58
|
+
for (const [name, value] of Object.entries(init?.headers ?? {})) {
|
|
59
|
+
headers.set(name, value);
|
|
60
|
+
}
|
|
61
|
+
// Only when the caller did not say — a `Content-Type` it chose itself
|
|
62
|
+
// was set above and is left alone.
|
|
63
|
+
if (init?.body !== undefined && !headers.has('Content-Type')) {
|
|
64
|
+
headers.set('Content-Type', 'application/json');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let response: Response;
|
|
68
|
+
try {
|
|
69
|
+
response = await fetch(resolveUrl(url, options.baseUrl), {
|
|
70
|
+
method: init?.method ?? 'GET',
|
|
71
|
+
headers,
|
|
72
|
+
body: init?.body,
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
// `fetch` reports every failure before a response — no connection,
|
|
76
|
+
// DNS, CORS, an abort, a URL it cannot parse — as a `TypeError`,
|
|
77
|
+
// which is also what a null dereference in page code raises. The
|
|
78
|
+
// two are indistinguishable downstream, so the fact is declared
|
|
79
|
+
// here: this is the seam that made the request and the only place
|
|
80
|
+
// that knows the throw came out of one. `markTransportFailure` is
|
|
81
|
+
// exported so a consumer's own client can say the same.
|
|
82
|
+
throw markTransportFailure(err);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// `Response` already satisfies `HttpResponse` structurally. Naming the
|
|
86
|
+
// type here is what keeps that true if either side changes.
|
|
87
|
+
return response satisfies HttpResponse;
|
|
88
|
+
};
|
|
89
|
+
}
|
package/src/client/http-json.ts
CHANGED
|
@@ -3,32 +3,98 @@
|
|
|
3
3
|
// instead of raw `fetch` with divergent error handling per component. A
|
|
4
4
|
// consumer `HttpClient` (auth header, baseURL, retry) then applies everywhere.
|
|
5
5
|
|
|
6
|
+
import { AdminError, markTransportFailure, readErrorCode, readErrorDetail } from './admin-error.js';
|
|
6
7
|
import type { HttpClient } from './types.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* a
|
|
10
|
+
* Whether a resolved `HttpResponse` describes an answer from the server.
|
|
11
|
+
*
|
|
12
|
+
* `HttpClient` is a bare function type, and an axios or XHR wrapper reports a
|
|
13
|
+
* network error, a CORS rejection or an abort by RESOLVING with `status: 0`
|
|
14
|
+
* rather than by rejecting — a shape the contract permits and its own
|
|
15
|
+
* documentation invites. Every caller that reads a body afterwards is assuming
|
|
16
|
+
* an answer arrived; this is the one place that decides what "arrived" means,
|
|
17
|
+
* so the two sentences it separates ("check your connection" against "check
|
|
18
|
+
* whether the change was applied") cannot drift apart per composable.
|
|
19
|
+
*
|
|
20
|
+
* `> 0` is the same boundary `AdminError` documents for its `status`.
|
|
12
21
|
*/
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
readonly status: number,
|
|
16
|
-
readonly code?: string,
|
|
17
|
-
) {
|
|
18
|
-
super(code ?? `HTTP ${status}`);
|
|
19
|
-
this.name = 'HttpJsonError';
|
|
20
|
-
}
|
|
22
|
+
function serverAnswered(status: number): boolean {
|
|
23
|
+
return status > 0;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Fails, at the seam that can still tell, when the client resolved without an
|
|
28
|
+
* HTTP status: the request never reached the server.
|
|
29
|
+
*
|
|
30
|
+
* Every caller below then knows that an absent body means the server answered
|
|
31
|
+
* without one, which is the precondition the empty-response sentinel needs and
|
|
32
|
+
* did not have — `if (!data)` was true for both facts, and the mutation
|
|
33
|
+
* sentinel told the operator of a request that never left the machine to go
|
|
34
|
+
* check whether their change had been applied.
|
|
35
|
+
*
|
|
36
|
+
* `raise` builds the error class of the calling seam from the diagnostic, so
|
|
37
|
+
* the caught error keeps saying which API it came from.
|
|
38
|
+
*/
|
|
39
|
+
export function requireServerAnswer(
|
|
40
|
+
status: number,
|
|
41
|
+
method: string,
|
|
42
|
+
url: string,
|
|
43
|
+
raise: (diagnostic: string) => Error,
|
|
44
|
+
): void {
|
|
45
|
+
if (serverAnswered(status)) return;
|
|
46
|
+
throw markTransportFailure(
|
|
47
|
+
raise(`${method} ${url} produced no HTTP status — the request did not reach the server`),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @deprecated Renamed to {@link AdminError}, which is the same class: an
|
|
53
|
+
* `instanceof HttpJsonError` check keeps working and now also matches errors
|
|
54
|
+
* raised elsewhere in the package. Construction changed — `AdminError` takes
|
|
55
|
+
* one options object instead of `(status, code)`.
|
|
56
|
+
*/
|
|
57
|
+
export const HttpJsonError = AdminError;
|
|
58
|
+
export type HttpJsonError = AdminError;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Reads the error body once, for both of the things it can carry: the
|
|
62
|
+
* machine-readable `code` a caller maps to its own wording, and the `message`
|
|
63
|
+
* that is the only text available when the code is unknown to it.
|
|
64
|
+
*/
|
|
65
|
+
async function readErrorBody(res: { json(): Promise<unknown> }): Promise<unknown> {
|
|
24
66
|
try {
|
|
25
|
-
|
|
26
|
-
return typeof body?.code === 'string' ? body.code : undefined;
|
|
67
|
+
return await res.json();
|
|
27
68
|
} catch {
|
|
69
|
+
// A non-JSON error body (an HTML error page, an empty response) is not
|
|
70
|
+
// itself a failure — the status is what the caller acts on.
|
|
28
71
|
return undefined;
|
|
29
72
|
}
|
|
30
73
|
}
|
|
31
74
|
|
|
75
|
+
async function failed(
|
|
76
|
+
res: { status: number; json(): Promise<unknown> },
|
|
77
|
+
method: string,
|
|
78
|
+
url: string,
|
|
79
|
+
): Promise<AdminError> {
|
|
80
|
+
const body = await readErrorBody(res);
|
|
81
|
+
return new AdminError({
|
|
82
|
+
status: res.status,
|
|
83
|
+
// Read here rather than left to the status: `status: 0` reaches
|
|
84
|
+
// `adminErrorMessage` as "nothing is known about this failure", and
|
|
85
|
+
// this seam knows better — it held the response.
|
|
86
|
+
transportFailure: !serverAnswered(res.status),
|
|
87
|
+
code: readErrorCode(body),
|
|
88
|
+
body,
|
|
89
|
+
url,
|
|
90
|
+
method,
|
|
91
|
+
// Shared with `toAdminError` on purpose: a NestJS `ValidationPipe`
|
|
92
|
+
// rejection carries `message` as an array, and reading it here in a
|
|
93
|
+
// second, narrower way is how those constraints went missing.
|
|
94
|
+
detail: readErrorDetail(body),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
32
98
|
/**
|
|
33
99
|
* Removes trailing slashes so an API prefix can be concatenated with paths
|
|
34
100
|
* that start with `/`. Deliberately index-based instead of
|
|
@@ -44,7 +110,7 @@ export function trimTrailingSlashes(url: string): string {
|
|
|
44
110
|
export async function getJson<T>(http: HttpClient, url: string): Promise<T> {
|
|
45
111
|
const res = await http(url);
|
|
46
112
|
if (res.status < 200 || res.status >= 300) {
|
|
47
|
-
throw
|
|
113
|
+
throw await failed(res, 'GET', url);
|
|
48
114
|
}
|
|
49
115
|
return (await res.json()) as T;
|
|
50
116
|
}
|
|
@@ -56,7 +122,7 @@ export async function postJson<T>(http: HttpClient, url: string, body: unknown):
|
|
|
56
122
|
body: JSON.stringify(body),
|
|
57
123
|
});
|
|
58
124
|
if (res.status < 200 || res.status >= 300) {
|
|
59
|
-
throw
|
|
125
|
+
throw await failed(res, 'POST', url);
|
|
60
126
|
}
|
|
61
127
|
return (await res.json()) as T;
|
|
62
128
|
}
|
|
@@ -23,9 +23,6 @@ export const discoveryMessages = defineMessages(
|
|
|
23
23
|
replacedBy: 'ersetzt durch {key}',
|
|
24
24
|
replaces: 'ersetzt: {keys}',
|
|
25
25
|
reapproveEmphasis: 'erneut freigeben',
|
|
26
|
-
errorDiscoveryHttp: 'Discovery-Endpoint antwortete mit HTTP {status}',
|
|
27
|
-
errorRescanHttp: 'Discovery-Rescan antwortete mit HTTP {status}',
|
|
28
|
-
errorCatalogHttp: 'Catalog-Entries-API antwortete mit HTTP {status}',
|
|
29
26
|
capList: {
|
|
30
27
|
orphanFeature:
|
|
31
28
|
'Feature im Katalog ohne implementierende Capability — blockiert im blocking-Strict-Mode das Plan-Publish.',
|
|
@@ -137,9 +134,6 @@ export const discoveryMessages = defineMessages(
|
|
|
137
134
|
replacedBy: 'replaced by {key}',
|
|
138
135
|
replaces: 'replaces: {keys}',
|
|
139
136
|
reapproveEmphasis: 'approve again',
|
|
140
|
-
errorDiscoveryHttp: 'Discovery endpoint responded with HTTP {status}',
|
|
141
|
-
errorRescanHttp: 'Discovery rescan responded with HTTP {status}',
|
|
142
|
-
errorCatalogHttp: 'Catalog entries API responded with HTTP {status}',
|
|
143
137
|
capList: {
|
|
144
138
|
orphanFeature:
|
|
145
139
|
'Feature in the catalog without an implementing capability — blocks plan publishing in blocking strict mode.',
|