@nutin/cli 2.1.1 → 3.0.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/CHANGELOG.md +268 -0
- package/README.md +13 -5
- package/bin/add-feature.mjs +1 -1
- package/bin/cli.mjs +1 -1
- package/bin/create-app.mjs +1 -1
- package/bin/update-app.mjs +1 -1
- package/lib/index.mjs +6 -2
- package/lib/src/add-feature/base-template-patcher.mjs +139 -0
- package/lib/src/add-feature/feature-adder.mjs +20 -9
- package/lib/src/add-feature/feature-context-builder.mjs +1 -0
- package/lib/src/add-feature/nutin-config-updater.mjs +34 -8
- package/lib/src/add-feature/package-json-updater.mjs +7 -9
- package/lib/src/common/feature-registry.mjs +4 -0
- package/lib/src/common/file-generator.mjs +25 -3
- package/lib/src/common/git.mjs +31 -0
- package/lib/src/common/meta-bootstrap-prompt.mjs +28 -2
- package/lib/src/common/package-json-helper.mjs +14 -4
- package/lib/src/common/project-meta.mjs +2 -2
- package/lib/src/common/template-compiler.mjs +2 -0
- package/lib/src/common/transpile-ts.mjs +94 -0
- package/lib/src/create-app/context-builder.mjs +1 -0
- package/lib/src/create-app/git-manager.mjs +12 -2
- package/lib/src/create-app/json-manager.mjs +21 -18
- package/lib/src/create-app/new-app-prompt.mjs +4 -2
- package/lib/src/create-app/success-message.mjs +1 -1
- package/lib/src/update-app/conflict-report-writer.mjs +33 -21
- package/lib/src/update-app/json-drift.mjs +54 -0
- package/lib/src/update-app/project-updater.mjs +123 -14
- package/lib/src/update-app/template-differ.mjs +5 -5
- package/lib/src/update-app/three-way-merge.mjs +42 -0
- package/lib/src/update-app/update-context-builder.mjs +1 -0
- package/lib/src/update-app/update-summary-printer.mjs +10 -2
- package/package.json +9 -8
- package/templates/base/AGENTS.md.hbs +42 -41
- package/templates/base/GETTING_STARTED.md.hbs +26 -20
- package/templates/base/README.md.hbs +7 -4
- package/templates/base/src/app/globals.d.ts.hbs +6 -3
- package/templates/base/src/app/guards.ts.hbs +1 -3
- package/templates/base/src/app/main.ts.hbs +2 -9
- package/templates/base/src/app/routes.ts.hbs +7 -2
- package/templates/base/src/app/views/home/home.view.html.hbs +1 -1
- package/templates/base/src/app/views/home/home.view.ts.hbs +2 -2
- package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +2 -2
- package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +461 -1
- package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +39 -48
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +36 -1
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +47 -77
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +286 -24
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +144 -26
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +66 -4
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +135 -75
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +85 -4
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +93 -54
- package/templates/base/src/core/base-classes/base-component/helpers/html.helper.test.js.hbs +404 -0
- package/templates/base/src/core/base-classes/base-component/helpers/html.helper.ts.hbs +242 -0
- package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +70 -1
- package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +54 -15
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +12 -1
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +39 -36
- package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.test.js.hbs +179 -0
- package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.ts.hbs +93 -0
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +135 -17
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +199 -81
- package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +40 -23
- package/templates/base/src/core/base-classes/base-component/helpers/token.helper.ts.hbs +67 -65
- package/templates/base/src/core/base-classes/component/component.test.js.hbs +17 -17
- package/templates/base/src/core/base-classes/component/component.ts.hbs +8 -41
- package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +1 -1
- package/templates/base/src/core/base-classes/component/helpers/config.helper.ts.hbs +37 -39
- package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +14 -1
- package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.ts.hbs +33 -31
- package/templates/base/src/core/base-classes/service/service.test.js.hbs +32 -0
- package/templates/base/src/core/base-classes/service/service.ts.hbs +21 -13
- package/templates/base/src/core/base-classes/view/view.test.js.hbs +4 -0
- package/templates/base/src/core/base-classes/view/view.ts.hbs +11 -8
- package/templates/base/src/core/index.ts.hbs +4 -2
- package/templates/base/src/core/internals.d.ts.hbs +199 -20
- package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +18 -0
- package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +26 -25
- package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +3 -3
- package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +3 -3
- package/templates/base/src/core/services/globals/globals.ts.hbs +7 -32
- package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +133 -50
- package/templates/base/src/core/services/http-client/helpers/http-builder.helper.ts.hbs +147 -39
- package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +75 -19
- package/templates/base/src/core/services/http-client/helpers/http-manager.helper.ts.hbs +73 -47
- package/templates/base/src/core/services/http-client/http-client.test.js.hbs +339 -232
- package/templates/base/src/core/services/http-client/http-client.ts.hbs +37 -25
- package/templates/base/src/core/services/i18n/i18n.test.js.hbs +57 -16
- package/templates/base/src/core/services/i18n/i18n.ts.hbs +42 -42
- package/templates/base/src/core/services/i18n/languages.ts.hbs +0 -2
- package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +4 -6
- package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +10 -0
- package/templates/base/src/core/services/pipe-registry/pipes.ts.hbs +4 -2
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +183 -4
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +207 -84
- package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +26 -27
- package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.ts.hbs +38 -60
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +4 -4
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +30 -30
- package/templates/base/src/core/services/router/router.test.js.hbs +306 -0
- package/templates/base/src/core/services/router/router.ts.hbs +113 -53
- package/templates/base/tools/builder/builder.config.js.hbs +6 -0
- package/templates/base/tools/builder/builder.js.hbs +18 -6
- package/templates/base/tools/builder/core/app/compile-ts.js.hbs +81 -6
- package/templates/base/tools/builder/core/app/copy-static.js.hbs +12 -1
- package/templates/base/tools/builder/core/app/handler-refs.js.hbs +114 -0
- package/templates/base/tools/builder/core/app/handler-refs.test.js.hbs +70 -0
- package/templates/base/tools/builder/core/app/paths.js.hbs +17 -0
- package/templates/base/tools/builder/core/app/route-keys.js.hbs +47 -0
- package/templates/base/tools/builder/core/app/route-keys.test.js.hbs +97 -0
- package/templates/base/tools/builder/core/app/ts-errors.js.hbs +58 -0
- package/templates/base/tools/builder/core/app/ts-errors.test.js.hbs +84 -0
- package/templates/base/tools/builder/core/app/validate-routes.js.hbs +21 -55
- package/templates/base/tools/builder/core/finalize-build.js.hbs +6 -0
- package/templates/base/tools/builder/core/html-index/validate-html.js.hbs +10 -2
- package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +111 -23
- package/templates/base/tools/builder/core/html-templates/minify-templates.js.hbs +8 -0
- package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +5 -3
- package/templates/base/tools/builder/core/html-templates/template-literal.js.hbs +193 -0
- package/templates/base/tools/builder/core/html-templates/template-literal.test.js.hbs +69 -0
- package/templates/base/tools/builder/core/i18n/build-i18n.js.hbs +14 -6
- package/templates/base/tools/builder/core/prod-bundle/bundle-options.js.hbs +82 -0
- package/templates/base/tools/builder/core/prod-bundle/bundle-options.test.js.hbs +65 -0
- package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +6 -6
- package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +15 -7
- package/templates/base/tools/builder/core/prod-bundle/hash-files.js.hbs +4 -4
- package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +31 -13
- package/templates/base/tools/builder/core/seo/generate-robots-txt.test.js.hbs +48 -0
- package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +3 -3
- package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +43 -13
- package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +22 -7
- package/templates/base/tools/builder/core/seo/generate-sitemap-xml.test.js.hbs +30 -0
- package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +28 -21
- package/templates/base/tools/builder/core/seo/html-substitutions.test.js.hbs +45 -0
- package/templates/base/tools/builder/core/seo/route-output.js.hbs +24 -6
- package/templates/base/tools/builder/core/seo/route-output.test.js.hbs +31 -0
- package/templates/base/tools/builder/core/seo/seo-routes.js.hbs +29 -0
- package/templates/base/tools/builder/core/seo/seo-routes.test.js.hbs +21 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +6 -7
- package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +34 -5
- package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.test.js.hbs +48 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +24 -11
- package/templates/base/tools/builder/core/styles/sass.js.hbs +3 -2
- package/templates/base/tools/builder/core/styles/tailwind.js.hbs +19 -59
- package/templates/base/tools/dev/dev-serve.js.hbs +30 -5
- package/templates/base/tools/dev/serve.js.hbs +152 -22
- package/templates/base/tools/dev/watcher.js.hbs +34 -3
- package/templates/base/tools/generator/generator.js.hbs +1 -1
- package/templates/base/tools/generator/handle-file.js.hbs +4 -2
- package/templates/base/tools/generator/handle-file.test.js.hbs +3 -3
- package/templates/base/tools/generator/templates/component/component.template.js.hbs +3 -3
- package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +3 -3
- package/templates/base/tools/generator/templates/service/service.template.js.hbs +1 -1
- package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +1 -1
- package/templates/base/tools/generator/templates/view/view.template.js.hbs +2 -2
- package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
- package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +1 -1
- package/templates/base/tools/testin-nutin/core/globals/assertion-lib.js.hbs +2 -2
- package/templates/base/tools/testin-nutin/core/{tests/assertions.test.js.hbs → globals/assertion-lib.test.js.hbs} +6 -0
- package/templates/base/tools/testin-nutin/core/globals/register-test-globals.js.hbs +6 -7
- package/templates/base/tools/testin-nutin/core/{tests/globals-each-all.test.js.hbs → globals/register-test-globals.test.js.hbs} +12 -0
- package/templates/base/tools/testin-nutin/core/printer.js.hbs +10 -4
- package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +8 -8
- package/templates/base/tools/testin-nutin/mocks/mock-event-bus.js.hbs +27 -25
- package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +30 -22
- package/templates/base/tools/testin-nutin/mocks/mock-http-client.js.hbs +7 -10
- package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +12 -0
- package/templates/base/tools/testin-nutin/mocks/mock-i18n.js.hbs +47 -22
- package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +47 -26
- package/templates/base/tools/testin-nutin/mocks/mock-router.js.hbs +19 -11
- package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +24 -7
- package/templates/base/tools/testin-nutin/runner.js.hbs +12 -3
- package/templates/base/tools/testin-nutin/watch-tests.js.hbs +45 -20
- package/templates/base/tools/utils/build-lock.js.hbs +30 -2
- package/templates/base/tools/utils/ensure-deps.js.hbs +72 -0
- package/templates/base/tools/utils/ensure-deps.test.js.hbs +17 -0
- package/templates/base/tools/utils/format.js.hbs +1 -1
- package/templates/base/tools/utils/index.js.hbs +2 -1
- package/templates/base/tools/utils/print.js.hbs +2 -2
- package/templates/base/tools/utils/resolve-port.js.hbs +30 -0
- package/templates/base/tools/utils/resolve-port.test.js.hbs +47 -0
- package/templates/base/tools/utils/run-command.js.hbs +30 -1
- package/templates/base/tools/utils/run-command.test.js.hbs +12 -1
- package/templates/base/tools/utils/run-script.js.hbs +2 -2
- package/templates/features/docker/tools/docker/Dockerfile.template.hbs +3 -0
- package/templates/features/docker/tools/docker/nginx.conf.template +6 -4
- package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +1 -1
- package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +2 -2
- package/templates/features/docker/tools/docker/scripts/locale-redirects.js.hbs +28 -0
- package/templates/features/docker/tools/docker/scripts/locale-redirects.test.js.hbs +34 -0
- package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.js.hbs +25 -0
- package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.test.js.hbs +34 -0
- package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +3 -1
- package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +12 -0
- package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +21 -4
- package/templates/features/markdown/markdown-content/getting-started.md.hbs +28 -0
- package/templates/features/markdown/markdown-content/writing-pages.md.hbs +19 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.scss.hbs +75 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.test.js.hbs +40 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.ts.hbs +65 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.test.js.hbs +67 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.ts.hbs +87 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.scss.hbs +42 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.test.js.hbs +59 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.ts.hbs +72 -0
- package/templates/features/markdown/src/app/markdown/locales/en.json.hbs +6 -0
- package/templates/features/markdown/src/app/markdown/markdown-guards.test.js.hbs +63 -0
- package/templates/features/markdown/src/app/markdown/markdown-guards.ts.hbs +31 -0
- package/templates/features/markdown/src/app/markdown/markdown-i18n.test.js.hbs +78 -0
- package/templates/features/markdown/src/app/markdown/markdown-i18n.ts.hbs +31 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifest.test.js.hbs +98 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifest.ts.hbs +157 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifests.service.test.js.hbs +87 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifests.service.ts.hbs +50 -0
- package/templates/features/markdown/src/app/markdown/markdown-routes.test.js.hbs +74 -0
- package/templates/features/markdown/src/app/markdown/markdown-routes.ts.hbs +26 -0
- package/templates/features/markdown/src/app/markdown/markdown-sources.test.js.hbs +22 -0
- package/templates/features/markdown/src/app/markdown/markdown-sources.ts.hbs +42 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.html.hbs +4 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.scss.hbs +19 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.test.js.hbs +125 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.ts.hbs +146 -0
- package/templates/features/markdown/tools/builder/core/markdown/generate-markdown-pages.js.hbs +71 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.js.hbs +582 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.test.js.hbs +390 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.js.hbs +155 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.test.js.hbs +112 -0
- /package/templates/base/tools/testin-nutin/core/{tests → globals}/clock.test.js.hbs +0 -0
package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs
CHANGED
|
@@ -1,35 +1,49 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
const mockAbortSignal = {
|
|
4
|
-
aborted: false,
|
|
5
|
-
onabort: null,
|
|
6
|
-
addEventListener: () => {},
|
|
7
|
-
removeEventListener: () => {},
|
|
8
|
-
dispatchEvent: () => true,
|
|
9
|
-
};
|
|
1
|
+
import * as HttpBuilder from '#root/dist/src/core/services/http-client/helpers/http-builder.helper.js';
|
|
2
|
+
import { path } from '#root/dist/src/core/services/http-client/helpers/http-builder.helper.js';
|
|
10
3
|
|
|
11
4
|
describe('HttpBuilder', () => {
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
const result = HttpBuilder.buildRequestBody(null);
|
|
15
|
-
expect(result).toBeUndefined();
|
|
16
|
-
});
|
|
5
|
+
// The request body, as buildRequestOptions() builds it
|
|
6
|
+
const bodyOf = (data) => HttpBuilder.buildRequestOptions('POST', data, {}, new Headers(), new AbortController().signal).body;
|
|
17
7
|
|
|
18
|
-
it('
|
|
19
|
-
|
|
20
|
-
expect(
|
|
8
|
+
it('sends no body for null or undefined data', () => {
|
|
9
|
+
expect(bodyOf(null)).toBeUndefined();
|
|
10
|
+
expect(bodyOf(undefined)).toBeUndefined();
|
|
21
11
|
});
|
|
22
12
|
|
|
23
|
-
it('
|
|
13
|
+
it('JSON-encodes an object body', () => {
|
|
24
14
|
const data = { name: 'Alice' };
|
|
25
|
-
|
|
26
|
-
|
|
15
|
+
expect(bodyOf(data)).toBe(JSON.stringify(data));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('JSON-encodes primitives, falsy ones included', () => {
|
|
19
|
+
expect(bodyOf(42)).toBe('42');
|
|
20
|
+
expect(bodyOf('text')).toBe('"text"');
|
|
21
|
+
expect(bodyOf(0)).toBe('0');
|
|
22
|
+
expect(bodyOf(false)).toBe('false');
|
|
23
|
+
expect(bodyOf('')).toBe('""');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('passes fetch body types through unchanged', () => {
|
|
27
|
+
const bodies = [new FormData(), new Blob(['x']), new URLSearchParams('a=1'), new ArrayBuffer(1), new Uint8Array(1), new ReadableStream()];
|
|
28
|
+
bodies.forEach((body) => expect(bodyOf(body)).toBe(body));
|
|
27
29
|
});
|
|
28
30
|
|
|
29
|
-
it('
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
expect(
|
|
31
|
+
it('appendQueryParams skips null/undefined, stringifies values and repeats arrays', () => {
|
|
32
|
+
const url = new URL('https://api.example.com');
|
|
33
|
+
HttpBuilder.appendQueryParams(url, { a: 1, b: false, c: null, d: undefined, e: ['x', null, 2] });
|
|
34
|
+
expect(url.search).toBe('?a=1&b=false&e=x&e=2');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('appendQueryParams does nothing without params', () => {
|
|
38
|
+
const url = new URL('https://api.example.com/?a=1');
|
|
39
|
+
HttpBuilder.appendQueryParams(url, undefined);
|
|
40
|
+
expect(url.search).toBe('?a=1');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('mergeHeaders merges case-insensitively, later records winning, and skips undefined', () => {
|
|
44
|
+
const headers = HttpBuilder.mergeHeaders({ 'Content-Type': 'a', 'X-One': '1' }, undefined, { 'content-type': 'b' });
|
|
45
|
+
expect(headers.get('content-type')).toBe('b');
|
|
46
|
+
expect(headers.get('x-one')).toBe('1');
|
|
33
47
|
});
|
|
34
48
|
|
|
35
49
|
// Tests for appendQueryParams
|
|
@@ -60,6 +74,65 @@ describe('HttpBuilder', () => {
|
|
|
60
74
|
});
|
|
61
75
|
|
|
62
76
|
// Tests for buildRequestUrl
|
|
77
|
+
it('resolveUrl keeps an absolute endpoint when there is no base URL', () => {
|
|
78
|
+
expect(HttpBuilder.resolveUrl('', 'https://api.example.com/users').href).toBe('https://api.example.com/users');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('resolveUrl resolves a relative endpoint against the page origin when there is no base URL', () => {
|
|
82
|
+
expect(HttpBuilder.resolveUrl('', '/api/users').href).toBe('http://localhost/api/users');
|
|
83
|
+
expect(HttpBuilder.resolveUrl('', 'api/users?x=1').href).toBe('http://localhost/api/users?x=1');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('resolveUrl joins endpoints under the base URL with or without slashes', () => {
|
|
87
|
+
expect(HttpBuilder.resolveUrl('https://api.example.com', '/users/1').href).toBe('https://api.example.com/users/1');
|
|
88
|
+
expect(HttpBuilder.resolveUrl('https://api.example.com/v1', '/users').href).toBe('https://api.example.com/v1/users');
|
|
89
|
+
expect(HttpBuilder.resolveUrl('https://api.example.com/v1/', 'users').href).toBe('https://api.example.com/v1/users');
|
|
90
|
+
expect(HttpBuilder.resolveUrl('https://api.example.com/v1', '').href).toBe('https://api.example.com/v1/');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('resolveUrl keeps host-like endpoints on the base origin', () => {
|
|
94
|
+
expect(HttpBuilder.resolveUrl('https://api.example.com', '@other.example/x').href).toBe('https://api.example.com/@other.example/x');
|
|
95
|
+
expect(HttpBuilder.resolveUrl('https://api.example.com', '.other.example/x').href).toBe('https://api.example.com/.other.example/x');
|
|
96
|
+
expect(HttpBuilder.resolveUrl('https://api.example.com', '//other.example/x').href).toBe('https://api.example.com/other.example/x');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('resolveUrl keeps the base URL query ahead of the endpoint query', () => {
|
|
100
|
+
expect(HttpBuilder.resolveUrl('https://api.example.com/v1?v=2', '/users?page=3').href).toBe('https://api.example.com/v1/users?v=2&page=3');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('resolveUrl only allows http and https', () => {
|
|
104
|
+
expect(HttpBuilder.resolveUrl('', 'http://api.example.com/a').protocol).toBe('http:');
|
|
105
|
+
expect(() => HttpBuilder.resolveUrl('', 'ftp://files.example.com/a')).toThrow('must use http or https');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('resolveUrl rejects an encoded slash or backslash in the path unless the URL is under a trusted API', () => {
|
|
109
|
+
expect(() => HttpBuilder.resolveUrl('https://api.example.com', '/files/a%2Fb')).toThrow('trustedAPIs');
|
|
110
|
+
expect(() => HttpBuilder.resolveUrl('https://api.example.com', '/files/a%5cb')).toThrow('trustedAPIs');
|
|
111
|
+
const trusted = ['https://api.example.com/files/'];
|
|
112
|
+
expect(HttpBuilder.resolveUrl('https://api.example.com', '/files/a%2Fb', trusted).pathname).toBe('/files/a%2Fb');
|
|
113
|
+
expect(() => HttpBuilder.resolveUrl('https://api.example.com', '/other/a%2Fb', trusted)).toThrow('trustedAPIs');
|
|
114
|
+
expect(() => HttpBuilder.resolveUrl('', 'https://cdn.example.com/files/a%2Fb', trusted)).toThrow('trustedAPIs');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('resolveUrl matches trusted APIs on whole path segments', () => {
|
|
118
|
+
const trusted = ['https://api.example.com/v1'];
|
|
119
|
+
expect(HttpBuilder.resolveUrl('', 'https://api.example.com/v1/a%2Fb', trusted).pathname).toBe('/v1/a%2Fb');
|
|
120
|
+
expect(() => HttpBuilder.resolveUrl('', 'https://api.example.com/v10/a%2Fb', trusted)).toThrow('trustedAPIs');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('describe shows origin and path only', () => {
|
|
124
|
+
expect(HttpBuilder.describe(new URL('https://api.example.com/a?key=secret#h'))).toBe('https://api.example.com/a');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('resolveUrl rejects absolute URLs on another origin', () => {
|
|
128
|
+
expect(() => HttpBuilder.resolveUrl('https://api.example.com', 'https://other.example/x')).toThrow();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('resolveUrl rejects endpoints that leave the base path', () => {
|
|
132
|
+
expect(() => HttpBuilder.resolveUrl('https://api.example.com/v1', '/users/../../admin')).toThrow();
|
|
133
|
+
expect(() => HttpBuilder.resolveUrl('https://api.example.com/v1', '%2e%2e/admin')).toThrow();
|
|
134
|
+
});
|
|
135
|
+
|
|
63
136
|
it('buildRequestUrl returns URL without query params', () => {
|
|
64
137
|
const url = HttpBuilder.buildRequestUrl('https://api.example.com');
|
|
65
138
|
expect(url.search).toBe('');
|
|
@@ -81,54 +154,64 @@ describe('HttpBuilder', () => {
|
|
|
81
154
|
});
|
|
82
155
|
|
|
83
156
|
// Tests for buildRequestOptions
|
|
84
|
-
it('buildRequestOptions sets GET without body', () => {
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
{ headers: { 'X-Test': 'value' } },
|
|
89
|
-
mockAbortSignal
|
|
90
|
-
);
|
|
157
|
+
it('buildRequestOptions sets GET without body or content type', () => {
|
|
158
|
+
const headers = new Headers({ 'X-Test': 'value' });
|
|
159
|
+
const signal = new AbortController().signal;
|
|
160
|
+
const options = HttpBuilder.buildRequestOptions('GET', null, {}, headers, signal);
|
|
91
161
|
expect(options.method).toBe('GET');
|
|
92
162
|
expect(options.body).toBeUndefined();
|
|
93
|
-
expect(
|
|
94
|
-
expect(options.
|
|
163
|
+
expect(options.headers.get('x-test')).toBe('value');
|
|
164
|
+
expect(options.headers.has('content-type')).toBe(false);
|
|
165
|
+
expect(options.signal).toBe(signal);
|
|
95
166
|
});
|
|
96
167
|
|
|
97
|
-
it('buildRequestOptions sets
|
|
98
|
-
const
|
|
168
|
+
it('buildRequestOptions sets a JSON body and content type', () => {
|
|
169
|
+
const options = HttpBuilder.buildRequestOptions('POST', { name: 'Alice' }, {}, new Headers(), new AbortController().signal);
|
|
170
|
+
expect(options.body).toBe('{"name":"Alice"}');
|
|
171
|
+
expect(options.headers.get('content-type')).toBe('application/json');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('buildRequestOptions keeps an explicit content type and passes fetch options through', () => {
|
|
99
175
|
const options = HttpBuilder.buildRequestOptions(
|
|
100
176
|
'POST',
|
|
101
|
-
|
|
102
|
-
{
|
|
103
|
-
|
|
177
|
+
{ a: 1 },
|
|
178
|
+
{ credentials: 'include', cache: 'no-store', referrerPolicy: 'no-referrer', redirect: 'manual' },
|
|
179
|
+
new Headers({ 'content-type': 'text/plain' }),
|
|
180
|
+
new AbortController().signal
|
|
104
181
|
);
|
|
105
|
-
expect(options.
|
|
106
|
-
expect(options.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const options = HttpBuilder.buildRequestOptions('GET', null, {}, mockAbortSignal);
|
|
111
|
-
expect(options.body).toBeUndefined();
|
|
112
|
-
expect(options.headers).toBeUndefined();
|
|
182
|
+
expect(options.headers.get('content-type')).toBe('text/plain');
|
|
183
|
+
expect(options.credentials).toBe('include');
|
|
184
|
+
expect(options.cache).toBe('no-store');
|
|
185
|
+
expect(options.referrerPolicy).toBe('no-referrer');
|
|
186
|
+
expect(options.redirect).toBe('manual');
|
|
113
187
|
});
|
|
114
188
|
|
|
115
189
|
it('buildRequestOptions sets PUT with body', () => {
|
|
116
190
|
const data = { id: 1, name: 'Updated' };
|
|
117
|
-
const options = HttpBuilder.buildRequestOptions('PUT', data, {},
|
|
191
|
+
const options = HttpBuilder.buildRequestOptions('PUT', data, {}, new Headers(), new AbortController().signal);
|
|
118
192
|
expect(options.method).toBe('PUT');
|
|
119
193
|
expect(options.body).toBe(JSON.stringify(data));
|
|
120
194
|
});
|
|
121
195
|
|
|
122
196
|
it('buildRequestOptions sets PATCH with body', () => {
|
|
123
197
|
const data = { name: 'Patched' };
|
|
124
|
-
const options = HttpBuilder.buildRequestOptions('PATCH', data, {},
|
|
198
|
+
const options = HttpBuilder.buildRequestOptions('PATCH', data, {}, new Headers(), new AbortController().signal);
|
|
125
199
|
expect(options.method).toBe('PATCH');
|
|
126
200
|
expect(options.body).toBe(JSON.stringify(data));
|
|
127
201
|
});
|
|
128
202
|
|
|
129
203
|
it('buildRequestOptions sets DELETE without body', () => {
|
|
130
|
-
const options = HttpBuilder.buildRequestOptions('DELETE', undefined, {},
|
|
204
|
+
const options = HttpBuilder.buildRequestOptions('DELETE', undefined, {}, new Headers(), new AbortController().signal);
|
|
131
205
|
expect(options.method).toBe('DELETE');
|
|
132
206
|
expect(options.body).toBeUndefined();
|
|
133
207
|
});
|
|
134
|
-
|
|
208
|
+
|
|
209
|
+
it('path encodes values and keeps the static parts', () => {
|
|
210
|
+
expect(path`/users/${'a b'}/files/${'x/y'}?v=${1}`).toBe('/users/a%20b/files/x%2Fy?v=1');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('path rejects "." and ".." values', () => {
|
|
214
|
+
expect(() => path`/a/${'.'}`).toThrow('not a valid path value');
|
|
215
|
+
expect(() => path`/a/${'..'}`).toThrow('not a valid path value');
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -1,48 +1,156 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
export class HttpBuilder {
|
|
15
|
-
public static buildRequestOptions(
|
|
16
|
-
method: HttpMethod,
|
|
17
|
-
data: unknown,
|
|
18
|
-
config: IRequestConfig,
|
|
19
|
-
signal: AbortSignal
|
|
20
|
-
): RequestInit {
|
|
21
|
-
return {
|
|
22
|
-
method,
|
|
23
|
-
headers: config.headers,
|
|
24
|
-
body: this.buildRequestBody(data),
|
|
25
|
-
signal
|
|
26
|
-
};
|
|
1
|
+
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
|
|
2
|
+
const ENCODED_SEPARATOR = /%2f|%5c/i;
|
|
3
|
+
|
|
4
|
+
export function buildRequestOptions(
|
|
5
|
+
method: Nutin.HttpMethod,
|
|
6
|
+
data: unknown,
|
|
7
|
+
config: Nutin.RequestConfig,
|
|
8
|
+
headers: Headers,
|
|
9
|
+
signal: AbortSignal
|
|
10
|
+
): RequestInit {
|
|
11
|
+
const body = buildRequestBody(data);
|
|
12
|
+
if (isJsonBody(data) && !headers.has('content-type')) {
|
|
13
|
+
headers.set('content-type', 'application/json');
|
|
27
14
|
}
|
|
28
15
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
16
|
+
const options: RequestInit = { method, headers, body, signal };
|
|
17
|
+
if (config.credentials) options.credentials = config.credentials;
|
|
18
|
+
if (config.cache) options.cache = config.cache;
|
|
19
|
+
if (config.referrerPolicy) options.referrerPolicy = config.referrerPolicy;
|
|
20
|
+
if (config.redirect) options.redirect = config.redirect;
|
|
21
|
+
return options;
|
|
22
|
+
}
|
|
35
23
|
|
|
36
|
-
|
|
37
|
-
|
|
24
|
+
// Merges header records case-insensitively: later records win ("content-type" overrides "Content-Type").
|
|
25
|
+
export function mergeHeaders(...records: Array<Record<string, string> | undefined>): Headers {
|
|
26
|
+
const headers = new Headers();
|
|
27
|
+
records.forEach((record) => {
|
|
28
|
+
if (record) new Headers(record).forEach((value, name) => headers.set(name, value));
|
|
29
|
+
});
|
|
30
|
+
return headers;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Resolves an endpoint to the URL that will be fetched.
|
|
34
|
+
// - With a base URL, the endpoint is joined onto it as a URL (never as a string), and the
|
|
35
|
+
// result must stay on the base origin and under the base path; the base's query is kept.
|
|
36
|
+
// - Without one, a relative endpoint resolves against the page's origin (not document.baseURI).
|
|
37
|
+
// Only http(s) is allowed, and an encoded "/" or "\" in the path is rejected (servers may decode
|
|
38
|
+
// it into a separator) unless the URL is under one of `trustedAPIs`.
|
|
39
|
+
export function resolveUrl(baseUrl: string, endpoint: string, trustedAPIs: string[] = []): URL {
|
|
40
|
+
const url = baseUrl ? resolveAgainstBase(baseUrl, endpoint) : new URL(endpoint, window.location.origin);
|
|
38
41
|
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
|
|
43
|
+
throw new Error(`Request URL must use http or https, got "${url.protocol}".`);
|
|
41
44
|
}
|
|
45
|
+
if (ENCODED_SEPARATOR.test(url.pathname) && !isTrusted(url, trustedAPIs)) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Request URL "${describe(url)}" contains an encoded "/" or "\\". ` +
|
|
48
|
+
'List its API in the HttpClient "trustedAPIs" option if it expects them.'
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return url;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildRequestUrl(endpoint: string, queryParams?: Nutin.RequestConfig['queryParams']): URL {
|
|
55
|
+
const url = new URL(endpoint);
|
|
56
|
+
appendQueryParams(url, queryParams);
|
|
57
|
+
return url;
|
|
58
|
+
}
|
|
42
59
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
60
|
+
export function appendQueryParams(url: URL, queryParams?: Nutin.RequestConfig['queryParams']): void {
|
|
61
|
+
if (!queryParams) return;
|
|
62
|
+
Object.entries(queryParams).forEach(([key, value]) => {
|
|
63
|
+
const values = Array.isArray(value) ? value : [value];
|
|
64
|
+
values.forEach((item) => {
|
|
65
|
+
if (item !== null && item !== undefined) url.searchParams.append(key, String(item));
|
|
46
66
|
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Origin + path only: query strings often carry keys or tokens.
|
|
71
|
+
export function describe(url: URL): string {
|
|
72
|
+
return `${url.origin}${url.pathname}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A followed redirect must end where the request itself was allowed to go: under the base
|
|
76
|
+
// URL, or (without one) on the requested origin. Otherwise the response is another origin's,
|
|
77
|
+
// and custom headers (unlike Authorization) were forwarded to it.
|
|
78
|
+
export function assertRedirectTarget(baseUrl: string, requested: URL, finalUrl: string): void {
|
|
79
|
+
const final = new URL(finalUrl);
|
|
80
|
+
const allowed = baseUrl ? isUnderBase(final, baseUrl) : final.origin === requested.origin;
|
|
81
|
+
if (!allowed) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Request to "${describe(requested)}" was redirected to "${describe(final)}", outside ` +
|
|
84
|
+
`${baseUrl ? `the HttpClient base URL "${describe(new URL(baseUrl))}"` : 'its origin'}.`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isUnderBase(url: URL, baseUrl: string): boolean {
|
|
90
|
+
const base = new URL(baseUrl);
|
|
91
|
+
const basePath = base.pathname.endsWith('/') ? base.pathname : `${base.pathname}/`;
|
|
92
|
+
const underBase = url.pathname.startsWith(basePath) || `${url.pathname}/` === basePath;
|
|
93
|
+
return url.origin === base.origin && underBase;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resolveAgainstBase(baseUrl: string, endpoint: string): URL {
|
|
97
|
+
const base = new URL(baseUrl);
|
|
98
|
+
const basePath = base.pathname.endsWith('/') ? base.pathname : `${base.pathname}/`;
|
|
99
|
+
const url = new URL(endpoint.replace(/^\/+/, ''), `${base.origin}${basePath}`);
|
|
100
|
+
|
|
101
|
+
if (!isUnderBase(url, baseUrl)) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Request URL "${describe(url)}" is outside the HttpClient base URL "${describe(base)}".`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Keep the base URL's own query (e.g. an API version), before the endpoint's.
|
|
108
|
+
if (base.search) {
|
|
109
|
+
const params = new URLSearchParams(base.search);
|
|
110
|
+
url.searchParams.forEach((value, key) => params.append(key, value));
|
|
111
|
+
url.search = params.toString();
|
|
112
|
+
}
|
|
113
|
+
return url;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Same segment boundary as the base URL: a trusted ".../v1" doesn't cover ".../v10".
|
|
117
|
+
function isTrusted(url: URL, trustedAPIs: string[]): boolean {
|
|
118
|
+
return trustedAPIs.some((entry) => isUnderBase(url, entry));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Only null/undefined mean "no body"; fetch's own body types are sent as they are.
|
|
122
|
+
function buildRequestBody(data: unknown): BodyInit | undefined {
|
|
123
|
+
if (data === null || data === undefined) return undefined;
|
|
124
|
+
if (isNativeBody(data)) return data as BodyInit;
|
|
125
|
+
return JSON.stringify(data);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isJsonBody(data: unknown): boolean {
|
|
129
|
+
return data !== null && data !== undefined && !isNativeBody(data);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isNativeBody(data: unknown): boolean {
|
|
133
|
+
return (
|
|
134
|
+
(typeof FormData !== 'undefined' && data instanceof FormData) ||
|
|
135
|
+
(typeof Blob !== 'undefined' && data instanceof Blob) ||
|
|
136
|
+
(typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams) ||
|
|
137
|
+
(typeof ReadableStream !== 'undefined' && data instanceof ReadableStream) ||
|
|
138
|
+
data instanceof ArrayBuffer ||
|
|
139
|
+
ArrayBuffer.isView(data)
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Builds an endpoint path from values: each value is URL-encoded, so it stays one path
|
|
144
|
+
// segment ("a/b" becomes "a%2Fb", which resolveUrl only accepts under `trustedAPIs`).
|
|
145
|
+
// "." and ".." are rejected: as a whole segment they would move up the path.
|
|
146
|
+
export function path(strings: TemplateStringsArray, ...values: unknown[]): string {
|
|
147
|
+
return strings.reduce((out, str, i) => out + encodePathValue(values[i - 1]) + str);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function encodePathValue(value: unknown): string {
|
|
151
|
+
const text = String(value);
|
|
152
|
+
if (text === '.' || text === '..') {
|
|
153
|
+
throw new Error(`path\`\`: "${text}" is not a valid path value.`);
|
|
47
154
|
}
|
|
155
|
+
return encodeURIComponent(text);
|
|
48
156
|
}
|
package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as HttpManager from '#root/dist/src/core/services/http-client/helpers/http-manager.helper.js';
|
|
2
|
+
import { HttpError } from '#root/dist/src/core/services/http-client/helpers/http-manager.helper.js';
|
|
2
3
|
|
|
3
4
|
describe('HttpManager', () => {
|
|
4
5
|
it('should create an AbortController with a timeout', () => {
|
|
@@ -9,25 +10,69 @@ describe('HttpManager', () => {
|
|
|
9
10
|
});
|
|
10
11
|
|
|
11
12
|
it('should create an AbortController without a timeout', () => {
|
|
12
|
-
const { controller, timeoutId } = HttpManager.createAbortController();
|
|
13
|
+
const { controller, timeoutId, timedOut } = HttpManager.createAbortController();
|
|
13
14
|
expect(controller).toBeInstanceOf(AbortController);
|
|
14
15
|
expect(timeoutId).toBe(null);
|
|
16
|
+
expect(timedOut()).toBe(false);
|
|
15
17
|
});
|
|
16
18
|
|
|
17
|
-
it('should
|
|
19
|
+
it('should mark the request as timed out when its timeout fires', () => {
|
|
20
|
+
useFakeTimers();
|
|
21
|
+
try {
|
|
22
|
+
const { controller, timedOut, cleanup } = HttpManager.createAbortController(5);
|
|
23
|
+
advanceTimersByTime(4);
|
|
24
|
+
expect(timedOut()).toBe(false);
|
|
25
|
+
advanceTimersByTime(1);
|
|
26
|
+
expect(controller.signal.aborted).toBe(true);
|
|
27
|
+
expect(timedOut()).toBe(true);
|
|
28
|
+
cleanup();
|
|
29
|
+
} finally {
|
|
30
|
+
useRealTimers();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should reject a timeout that is not a positive number', () => {
|
|
35
|
+
expect(() => HttpManager.createAbortController(0)).toThrow('positive number');
|
|
36
|
+
expect(() => HttpManager.createAbortController(-5)).toThrow('positive number');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should forward an abort from the caller signal, and stop forwarding after cleanup', () => {
|
|
40
|
+
const caller = new AbortController();
|
|
41
|
+
const first = HttpManager.createAbortController(undefined, caller.signal);
|
|
42
|
+
caller.abort();
|
|
43
|
+
expect(first.controller.signal.aborted).toBe(true);
|
|
44
|
+
expect(first.timedOut()).toBe(false);
|
|
45
|
+
|
|
46
|
+
const other = new AbortController();
|
|
47
|
+
const second = HttpManager.createAbortController(undefined, other.signal);
|
|
48
|
+
second.cleanup();
|
|
49
|
+
other.abort();
|
|
50
|
+
expect(second.controller.signal.aborted).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should start aborted when the caller signal already is', () => {
|
|
54
|
+
const caller = new AbortController();
|
|
55
|
+
caller.abort();
|
|
56
|
+
expect(HttpManager.createAbortController(undefined, caller.signal).controller.signal.aborted).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('parses a JSON error response into the HttpError', async () => {
|
|
18
60
|
const mockResponse = new Response(JSON.stringify({ error: 'Invalid' }), {
|
|
61
|
+
status: 422,
|
|
19
62
|
headers: { 'Content-Type': 'application/json' }
|
|
20
63
|
});
|
|
21
|
-
const
|
|
22
|
-
expect(JSON.stringify(
|
|
64
|
+
const error = await HttpManager.validateResponse(mockResponse).catch((e) => e);
|
|
65
|
+
expect(JSON.stringify(error.response)).toBe(JSON.stringify({ error: 'Invalid' }));
|
|
23
66
|
});
|
|
24
67
|
|
|
25
|
-
it('
|
|
68
|
+
it('gives the HttpError a null response for a non-JSON error body', async () => {
|
|
26
69
|
const mockResponse = new Response('Not JSON', {
|
|
70
|
+
status: 500,
|
|
27
71
|
headers: { 'Content-Type': 'text/plain' }
|
|
28
72
|
});
|
|
29
|
-
const
|
|
30
|
-
expect(
|
|
73
|
+
const error = await HttpManager.validateResponse(mockResponse).catch((e) => e);
|
|
74
|
+
expect(error.status).toBe(500);
|
|
75
|
+
expect(error.response).toBe(null);
|
|
31
76
|
});
|
|
32
77
|
|
|
33
78
|
it('should not throw for ok responses in validateResponse', async () => {
|
|
@@ -75,11 +120,14 @@ describe('HttpManager', () => {
|
|
|
75
120
|
expect(thrown).toBe(true);
|
|
76
121
|
});
|
|
77
122
|
|
|
78
|
-
it('
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
123
|
+
it('parses JSON and +json content types, and returns anything else as text', async () => {
|
|
124
|
+
const parse = (contentType) => HttpManager.parseSuccessResponse(new Response('{"a":1}', contentType ? { headers: { 'Content-Type': contentType } } : {}));
|
|
125
|
+
for (const type of ['application/json', 'application/json; charset=utf-8', 'Application/JSON', 'application/problem+json']) {
|
|
126
|
+
expect(await parse(type)).toEqual({ a: 1 });
|
|
127
|
+
}
|
|
128
|
+
for (const type of ['application/jsonp', 'text/html', null]) {
|
|
129
|
+
expect(await parse(type)).toBe('{"a":1}');
|
|
130
|
+
}
|
|
83
131
|
});
|
|
84
132
|
|
|
85
133
|
it('should parse success JSON response', async () => {
|
|
@@ -90,6 +138,13 @@ describe('HttpManager', () => {
|
|
|
90
138
|
expect(JSON.stringify(result)).toBe(JSON.stringify({ message: 'ok' }));
|
|
91
139
|
});
|
|
92
140
|
|
|
141
|
+
it('should resolve 204, 205 and empty bodies to undefined', async () => {
|
|
142
|
+
expect(await HttpManager.parseSuccessResponse(new Response(null, { status: 204 }))).toBe(undefined);
|
|
143
|
+
expect(await HttpManager.parseSuccessResponse(new Response(null, { status: 205 }))).toBe(undefined);
|
|
144
|
+
const empty = new Response('', { headers: { 'Content-Type': 'application/json' } });
|
|
145
|
+
expect(await HttpManager.parseSuccessResponse(empty)).toBe(undefined);
|
|
146
|
+
});
|
|
147
|
+
|
|
93
148
|
it('should parse success text response as fallback', async () => {
|
|
94
149
|
const mockResponse = new Response('plain text', {
|
|
95
150
|
headers: { 'Content-Type': 'text/plain' }
|
|
@@ -98,19 +153,20 @@ describe('HttpManager', () => {
|
|
|
98
153
|
expect(result).toBe('plain text');
|
|
99
154
|
});
|
|
100
155
|
|
|
101
|
-
it('
|
|
102
|
-
|
|
103
|
-
expect(HttpManager.
|
|
156
|
+
it('reports only an AbortError as a timeout', () => {
|
|
157
|
+
expect(() => HttpManager.handleRequestError(new DOMException('Aborted', 'AbortError'), true)).toThrow('Request timed out');
|
|
158
|
+
expect(() => HttpManager.handleRequestError(new Error('Other'), true)).toThrow('Other');
|
|
159
|
+
});
|
|
104
160
|
|
|
105
|
-
|
|
106
|
-
expect(HttpManager.
|
|
161
|
+
it('should rethrow an abort that was not the timeout as it is', () => {
|
|
162
|
+
expect(() => HttpManager.handleRequestError(new DOMException('Aborted', 'AbortError'))).toThrow('Aborted');
|
|
107
163
|
});
|
|
108
164
|
|
|
109
165
|
it('should throw timeout error from handleRequestError', () => {
|
|
110
166
|
const err = new DOMException('Aborted', 'AbortError');
|
|
111
167
|
let thrown = false;
|
|
112
168
|
try {
|
|
113
|
-
HttpManager.handleRequestError(err);
|
|
169
|
+
HttpManager.handleRequestError(err, true);
|
|
114
170
|
} catch (e) {
|
|
115
171
|
thrown = true;
|
|
116
172
|
expect(e.message).toBe('Request timed out');
|