From 4a063721de14d18acecb2a291741682191eb5c2c Mon Sep 17 00:00:00 2001 From: Michael Beckemeyer Date: Mon, 18 Dec 2023 17:05:17 +0100 Subject: [PATCH 1/4] Implement request interceptors --- .changeset/kind-pans-jump.md | 5 + .changeset/young-wolves-clap.md | 9 + pnpm-lock.yaml | 6 + src/packages/core/error.ts | 13 +- src/packages/http/HttpServiceImpl.test.ts | 313 ++++++++++++++++++ src/packages/http/HttpServiceImpl.ts | 118 +++++++ src/packages/http/README.md | 34 +- src/packages/http/api.ts | 84 +++++ src/packages/http/build.config.mjs | 8 +- src/packages/http/index.ts | 22 +- src/packages/http/package.json | 3 +- src/packages/http/services.ts | 8 +- .../http-app/ExampleInterceptor.ts | 14 + .../http-sample/http-app/build.config.mjs | 3 + src/samples/http-sample/http-app/package.json | 1 + src/samples/http-sample/http-app/services.ts | 1 + 16 files changed, 609 insertions(+), 33 deletions(-) create mode 100644 .changeset/kind-pans-jump.md create mode 100644 .changeset/young-wolves-clap.md create mode 100644 src/packages/http/HttpServiceImpl.test.ts create mode 100644 src/packages/http/HttpServiceImpl.ts create mode 100644 src/packages/http/api.ts create mode 100644 src/samples/http-sample/http-app/ExampleInterceptor.ts diff --git a/.changeset/kind-pans-jump.md b/.changeset/kind-pans-jump.md new file mode 100644 index 00000000..2b62a7a1 --- /dev/null +++ b/.changeset/kind-pans-jump.md @@ -0,0 +1,5 @@ +--- +"@open-pioneer/core": minor +--- + +New helper function `rethrowAbortError`. diff --git a/.changeset/young-wolves-clap.md b/.changeset/young-wolves-clap.md new file mode 100644 index 00000000..3216edc2 --- /dev/null +++ b/.changeset/young-wolves-clap.md @@ -0,0 +1,9 @@ +--- +"@open-pioneer/http": minor +--- + +New feature: request interceptors. +Request interceptors can be registered with the `HttpService` to modify requests before they are sent to a server. +Request interceptors are called automatically by the `HttpService` when they are present as part of the normal request processing. + +Example use case: adding an access token (query parameter or header) to requests for a certain resource. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cc34742..690fb14b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -225,6 +225,9 @@ importers: src/packages/http: dependencies: + '@open-pioneer/core': + specifier: workspace:^ + version: link:../core '@open-pioneer/runtime': specifier: workspace:^ version: link:../runtime @@ -367,6 +370,9 @@ importers: '@open-pioneer/chakra-integration': specifier: workspace:^ version: link:../../../packages/chakra-integration + '@open-pioneer/core': + specifier: workspace:^ + version: link:../../../packages/core '@open-pioneer/http': specifier: workspace:^ version: link:../../../packages/http diff --git a/src/packages/core/error.ts b/src/packages/core/error.ts index 8cd041f9..d05dd7e1 100644 --- a/src/packages/core/error.ts +++ b/src/packages/core/error.ts @@ -52,8 +52,8 @@ export function getErrorChain(err: globalThis.Error): globalThis.Error[] { /** * Returns true if the error represents an abort error. */ -export function isAbortError(err: unknown) { - return err && typeof err === "object" && "name" in err && err.name === "AbortError"; +export function isAbortError(err: unknown): boolean { + return !!(err && typeof err === "object" && "name" in err && err.name === "AbortError"); } /** @@ -63,6 +63,15 @@ export function throwAbortError(): never { throw createAbortError(); } +/** + * Throws `err` if it is an abort error. Does nothing otherwise. + */ +export function rethrowAbortError(err: unknown): void { + if (isAbortError(err)) { + throw err; + } +} + /** * Returns an abort error (`.name` === `"AbortError"`). */ diff --git a/src/packages/http/HttpServiceImpl.test.ts b/src/packages/http/HttpServiceImpl.test.ts new file mode 100644 index 00000000..1a252101 --- /dev/null +++ b/src/packages/http/HttpServiceImpl.test.ts @@ -0,0 +1,313 @@ +// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) +// SPDX-License-Identifier: Apache-2.0 +/** + * @vitest-environment happy-dom + */ +import { isAbortError, throwAbortError } from "@open-pioneer/core"; +import { createService } from "@open-pioneer/test-utils/services"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { HttpServiceImpl } from "./HttpServiceImpl"; +import { Interceptor } from "./api"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +it("should invoke fetch", async () => { + const { service, getRequestCount, getLastRequest } = await setup(); + const response = await service.fetch("https://example.com/foo?bar=baz"); + + expect(getRequestCount()).toBe(1); + expect(getLastRequest()?.url).toMatchInlineSnapshot('"https://example.com/foo?bar=baz"'); + + expect(response.status).toBe(200); + expect(await response.text()).toBe("ok"); +}); + +it("transports request errors", async () => { + const { service } = await setup({ fetchResponse: errorResponse(404) }); + const response = await service.fetch("https://example.com"); + expect(response.status).toBe(404); +}); + +it("performs requests relative to the current origin", async () => { + const { service, getLastRequest } = await setup({ + location: "https://example.com/some/path" + }); + await service.fetch("/foo?bar=baz"); + + expect(getLastRequest()?.url).toMatchInlineSnapshot('"https://example.com/foo?bar=baz"'); +}); + +it("performs requests relative to the current location", async () => { + const { service, getLastRequest } = await setup({ + location: "https://example.com/some/path/index.html" + }); + await service.fetch("./foo?bar=baz"); + + expect(getLastRequest()?.url).toMatchInlineSnapshot( + '"https://example.com/some/path/foo?bar=baz"' + ); +}); + +it("supports cancellation", async () => { + const abortController = new AbortController(); + const { service } = await setup({ + async fetchResponse(req) { + await waitForAbort(req.signal); + throwAbortError(); + } + }); + + const promise = service.fetch("./foo?bar=baz", { signal: abortController.signal }); + abortController.abort(); + await expect(promise).rejects.toSatisfy(isAbortError); +}); + +describe("before request interceptors", () => { + it("invokes interceptors in order", async () => { + const events: string[] = []; + const interceptors: Interceptor[] = [ + { + beforeRequest({ target }) { + events.push("1: " + target.href); + } + }, + { + beforeRequest({ target }) { + events.push("2: " + target.href); + } + } + ]; + const { service } = await setup({ interceptors }); + await service.fetch("https://example.com"); + expect(events).toMatchInlineSnapshot(` + [ + "1: https://example.com/", + "2: https://example.com/", + ] + `); + }); + + it("supports asynchronous interceptors", async () => { + vi.useFakeTimers(); + + const timeout = 5000; + const interceptor: Interceptor = { + async beforeRequest(params) { + await new Promise((resolve) => { + setTimeout(resolve, timeout); + }); + + params.target = new URL("https://foo.bar/other-path"); + } + }; + const { service, getLastRequest } = await setup({ interceptors: [interceptor] }); + const promise = service.fetch("https://example.com/bar"); + vi.advanceTimersByTime(timeout); + await promise; + expect(getLastRequest()?.url).toMatchInlineSnapshot('"https://foo.bar/other-path"'); + }); + + it("allows interceptors to add query parameters", async () => { + const interceptor: Interceptor = { + beforeRequest(params) { + params.target.searchParams.append("token", "foo"); + } + }; + const { service, getLastRequest } = await setup({ interceptors: [interceptor] }); + await service.fetch("https://example.com/bar"); + expect(getLastRequest()?.url).toMatchInlineSnapshot('"https://example.com/bar?token=foo"'); + }); + + it("allows interceptors to replace the target URL", async () => { + const interceptor: Interceptor = { + beforeRequest(params) { + params.target = new URL("https://foo.bar/other-path"); + } + }; + const { service, getLastRequest } = await setup({ interceptors: [interceptor] }); + await service.fetch("https://example.com/bar"); + expect(getLastRequest()?.url).toMatchInlineSnapshot('"https://foo.bar/other-path"'); + }); + + it("allows interceptors to add custom http headers", async () => { + const interceptor: Interceptor = { + beforeRequest(params) { + params.options.headers.set("X-CUSTOM-TOKEN", "1234"); + } + }; + const { service, getLastRequest } = await setup({ interceptors: [interceptor] }); + await service.fetch("https://example.com/bar"); + + const lastRequest = getLastRequest(); + expect(lastRequest?.headers.get("X-CUSTOM-TOKEN")).toBe("1234"); + }); + + it("allows interceptors to modify various request options", async () => { + const interceptor: Interceptor = { + beforeRequest(params) { + params.options.credentials = "include"; + params.options.method = "PUT"; + } + }; + const { service, getLastRequest } = await setup({ interceptors: [interceptor] }); + await service.fetch("https://example.com/"); + + const lastRequest = getLastRequest(); + expect(lastRequest?.credentials).toBe("include"); + expect(lastRequest?.method).toBe("PUT"); + }); + + it("supports per-request context properties", async () => { + const symbol = Symbol("some_symbol"); + const props: Record = {}; + const { service } = await setup({ + interceptors: [ + { + beforeRequest({ context }) { + props["symbol_prop"] = context[symbol]; + props["string_prop"] = context["foo"]; + } + } + ] + }); + + await service.fetch("https://example.com", { + context: { + [symbol]: "SYMBOL", + foo: "STRING" + } + }); + + expect(props).toMatchInlineSnapshot(` + { + "string_prop": "STRING", + "symbol_prop": "SYMBOL", + } + `); + }); + + it("allows mutation of context from inside interceptors", async () => { + const values: unknown[] = []; + const interceptor: Interceptor = { + beforeRequest({ context }) { + const existing = context.value; + values.push(existing); + + context.value = typeof existing === "number" ? existing + 1 : 0; + } + }; + const { service } = await setup({ + interceptors: [interceptor, interceptor, interceptor, interceptor] + }); + await service.fetch("https://example.com"); + expect(values).toMatchInlineSnapshot(` + [ + undefined, + 0, + 1, + 2, + ] + `); + }); + + it("allows interceptors to spawn another request", async () => { + let calls = 0; + const interceptor: Interceptor = { + async beforeRequest({ context }) { + ++calls; + + if (context.skipRecurse) { + return; + } + + await service.fetch("https://example.com/second", { + context: { + skipRecurse: true + } + }); + } + }; + + const { service, getRequests } = await setup({ interceptors: [interceptor] }); + await service.fetch("https://example.com/first"); + + expect(calls).toBe(2); + expect(getRequests().map((r) => r.url)).toMatchInlineSnapshot(` + [ + "https://example.com/second", + "https://example.com/first", + ] + `); + }); +}); + +async function setup(options?: { + fetchResponse?: Response | ((req: Request) => Response | Promise); + location?: string; + interceptors?: Interceptor[]; +}) { + const requests: Request[] = []; + const fetchImpl = vi.fn().mockImplementation(async (req: Request) => { + requests.push(req); + + const response = options?.fetchResponse; + if (!response) { + return okResponse(); + } + return typeof response === "function" ? await response(req) : response; + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(window, "fetch" as any, "get").mockReturnValue(fetchImpl as any); + vi.spyOn(window.location, "href", "get").mockReturnValue( + options?.location ?? "https://example.com:3000/" + ); + + const service = await createService(HttpServiceImpl, { + references: { + interceptors: options?.interceptors ?? [] + } + }); + return { + service, + getRequests() { + return requests; + }, + getRequestCount() { + return requests.length; + }, + getLastRequest() { + return requests[requests.length - 1]; + } + }; +} + +function waitForAbort(signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) { + return resolve(); + } + + const handler = () => { + signal.removeEventListener("abort", handler); + resolve(); + }; + signal.addEventListener("abort", handler); + }); +} + +function okResponse() { + return new Response("ok", { + status: 200, + statusText: "OK" + }); +} + +function errorResponse(code = 404) { + return new Response("errorĀ“", { + status: code + }); +} diff --git a/src/packages/http/HttpServiceImpl.ts b/src/packages/http/HttpServiceImpl.ts new file mode 100644 index 00000000..1caec7da --- /dev/null +++ b/src/packages/http/HttpServiceImpl.ts @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) +// SPDX-License-Identifier: Apache-2.0 +import { throwAbortError } from "@open-pioneer/core"; +import { rethrowAbortError } from "@open-pioneer/core/error"; +import { ServiceOptions } from "@open-pioneer/runtime"; +import type { + BeforeRequestParams, + HttpService, + HttpServiceRequestInit, + Interceptor, + ResolvedRequestOptions +} from "./api"; + +interface References { + interceptors: Interceptor[]; +} + +export class HttpServiceImpl implements HttpService { + #interceptors: [id: string, interceptor: Interceptor][]; + + constructor(options: ServiceOptions) { + this.#interceptors = options.references.interceptors.map( + (interceptor, index): [string, Interceptor] => { + const id = options.referencesMeta.interceptors[index]!.serviceId; + return [id, interceptor]; + } + ); + } + + async fetch( + resource: string | URL, + init?: HttpServiceRequestInit | undefined + ): Promise { + const signal = init?.signal ?? undefined; + const context = Object.assign({}, init?.context); + const options = resolveOptions(init); + checkAborted(signal); + + // Invoke request interceptors before the request is made. + // Interceptors can modify the target URL and other request properties (such as headers). + let target = getTargetUrl(resource); + { + const params: BeforeRequestParams = { + target, + signal: signal ?? new AbortController().signal, + context, + options + }; + checkAborted(signal); + await this.#invokeBeforeRequestInterceptors(params); + target = params.target; + } + + // Perform the actual request. + const request = new Request(target, { + ...options, + signal + }); + return await window.fetch(request); + } + + async #invokeBeforeRequestInterceptors(params: BeforeRequestParams) { + const { signal } = params; + + for (const [id, interceptor] of this.#interceptors) { + checkAborted(signal); + if (interceptor.beforeRequest) { + // NOTE: may change 'params.target' + try { + await interceptor.beforeRequest(params); + } catch (e) { + rethrowAbortError(e); + throw new Error(`Interceptor '${id}' failed with an error`, { cause: e }); + } + } + } + } +} + +function getTargetUrl(rawTarget: string | URL): URL { + if (typeof rawTarget === "string") { + return new URL(rawTarget, window.location.href); // relative to current origin + } + return rawTarget; +} + +function resolveOptions(init: HttpServiceRequestInit | undefined): ResolvedRequestOptions { + const method = init?.method ?? "GET"; + const headers = new Headers(init?.headers ?? {}); + const options = { + ...init, + method, + headers + }; + for (const key in options) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (removeAttribute[key as keyof typeof removeAttribute]) { + delete options[key as keyof typeof options]; + } + } + return options as ResolvedRequestOptions; +} + +// Attributes that should be filtered because they should not be provided to interceptors +// via the `options` parameter. +const removeAttribute: Record< + Exclude, + 1 +> = { + context: 1, + signal: 1 +}; + +function checkAborted(signal: AbortSignal | undefined) { + if (signal?.aborted) { + throwAbortError(); + } +} diff --git a/src/packages/http/README.md b/src/packages/http/README.md index 7b257688..bdb8040e 100644 --- a/src/packages/http/README.md +++ b/src/packages/http/README.md @@ -41,7 +41,39 @@ export class MyService { ``` The signature of the `fetch()` method is compatible to the Browser's global [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) function. -However, the `HttpService`'s method should always be preferred to take advantage of future features (logging, request interceptors, etc.). +However, the `HttpService`'s method should always be preferred to take advantage of future features (logging, proxy support, etc.). + +## Request interceptors + +The `HttpService` supports extension via _request interceptors_. +Request interceptors can modify requests (query parameters, headers, etc.) before they are sent to the server. + +To register a request interceptor, implement a service that provides `"http.Interceptor"`: + +```js +// build.config.mjs +import { defineBuildConfig } from "@open-pioneer/build-support"; + +export default defineBuildConfig({ + services: { + ExampleInterceptor: { + provides: "http.Interceptor" + } + // ... + } + // ... +}); +``` + +```ts +// ExampleInterceptor.ts +import { Interceptor, BeforeRequestParams } from "@open-pioneer/http"; +export class ExampleInterceptor implements Interceptor { + async beforeRequest?(params: BeforeRequestParams) { + // Invoked for every request. See API documentation for more details. + } +} +``` ## License diff --git a/src/packages/http/api.ts b/src/packages/http/api.ts new file mode 100644 index 00000000..d56a1c7a --- /dev/null +++ b/src/packages/http/api.ts @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) +// SPDX-License-Identifier: Apache-2.0 + +import { DeclaredService } from "@open-pioneer/runtime"; + +/** + * Central service for sending HTTP requests. + * + * Use the interface `"http.HttpService"` to obtain an instance of this service. + */ +export interface HttpService extends DeclaredService<"http.HttpService"> { + /** + * Requests the given `resource` via HTTP and returns the response. + * + * See [fetch documentation](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) for reference. + * + * > NOTE: In its current implementation this is a simple wrapper around the browser's fetch. + * > Future versions may implement additional features on top of `fetch()` (such as support for a proxy backend). + * > + * > This service should still be used (instead of plain fetch) to automatically benefit from + * > future developments. + */ + fetch(resource: string | URL, init?: HttpServiceRequestInit): Promise; +} + +/** + * Options for {@link HttpService.fetch}. + */ +export interface HttpServiceRequestInit extends RequestInit { + /** + * Arbitrary context properties for this http request. + * These values can be accessed by interceptors. + */ + context?: ContextData; +} + +export type ResolvedRequestOptions = Omit< + HttpServiceRequestInit, + "method" | "headers" | "signal" | "context" +> & { + method: string; + headers: Headers; +}; + +export interface BeforeRequestParams { + /** + * The request's target URL, including query parameters. + * + * This property can be changed by the interceptor. + */ + target: URL; + + /** + * The options that were used when the request was made. + * Option values (such as headers) can be modified by an interceptor. + */ + readonly options: ResolvedRequestOptions; + + /** + * The context object holds arbitrary values associated with this http request. + * Interceptors can read and modify values within this object. + */ + readonly context: ContextData; + + /** + * The signal can be used to listen for cancellation. + * This is useful if an interceptor may run for a longer time. + */ + readonly signal: AbortSignal; +} + +export type ContextData = Record; + +/** + * Http interceptors can intercept HTTP requests made by the {@link HttpService}. + * + * Interceptors can be used, for example, to add additional query parameters or http headers + * or to manipulate a backend response. + * + * Use the interface name `http.Interceptor` to provide an implementation of this interface. + */ +export interface Interceptor extends DeclaredService<"http.Interceptor"> { + beforeRequest?(params: BeforeRequestParams): void | Promise; +} diff --git a/src/packages/http/build.config.mjs b/src/packages/http/build.config.mjs index c8e2cdf3..13e87154 100644 --- a/src/packages/http/build.config.mjs +++ b/src/packages/http/build.config.mjs @@ -6,7 +6,13 @@ export default defineBuildConfig({ entryPoints: "index", services: { HttpServiceImpl: { - provides: "http.HttpService" + provides: "http.HttpService", + references: { + interceptors: { + name: "http.Interceptor", + all: true + } + } } } }); diff --git a/src/packages/http/index.ts b/src/packages/http/index.ts index 701efdd8..5914fbc7 100644 --- a/src/packages/http/index.ts +++ b/src/packages/http/index.ts @@ -1,23 +1,3 @@ // SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) // SPDX-License-Identifier: Apache-2.0 -import { DeclaredService } from "@open-pioneer/runtime"; - -/** - * Central service for sending HTTP requests. - * - * Use the interface `"http.HttpService"` to obtain an instance of this service. - */ -export interface HttpService extends DeclaredService<"http.HttpService"> { - /** - * Requests the given `resource` via HTTP and returns the response. - * - * See [fetch documentation](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) for reference. - * - * > NOTE: In its current implementation this is a simple wrapper around the browser's fetch. - * > Future versions may implement additional features on top of `fetch()` (such as support for a proxy backend). - * > - * > This service should still be used (instead of plain fetch) to automatically benefit from - * > future developments. - */ - fetch(resource: RequestInfo | URL, init?: RequestInit): Promise; -} +export * from "./api"; diff --git a/src/packages/http/package.json b/src/packages/http/package.json index bbafa16e..035351ea 100644 --- a/src/packages/http/package.json +++ b/src/packages/http/package.json @@ -7,7 +7,8 @@ "build": "build-pioneer-package" }, "peerDependencies": { - "@open-pioneer/runtime": "workspace:^" + "@open-pioneer/runtime": "workspace:^", + "@open-pioneer/core": "workspace:^" }, "devDependencies": { "@open-pioneer/test-utils": "workspace:^", diff --git a/src/packages/http/services.ts b/src/packages/http/services.ts index 01a7f02b..ab0b2201 100644 --- a/src/packages/http/services.ts +++ b/src/packages/http/services.ts @@ -1,9 +1,3 @@ // SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) // SPDX-License-Identifier: Apache-2.0 -import type { HttpService } from "./index"; - -export class HttpServiceImpl implements HttpService { - async fetch(resource: RequestInfo | URL, init?: RequestInit | undefined): Promise { - return await globalThis.fetch(resource, init); - } -} +export { HttpServiceImpl } from "./HttpServiceImpl"; diff --git a/src/samples/http-sample/http-app/ExampleInterceptor.ts b/src/samples/http-sample/http-app/ExampleInterceptor.ts new file mode 100644 index 00000000..c8a472d4 --- /dev/null +++ b/src/samples/http-sample/http-app/ExampleInterceptor.ts @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) +// SPDX-License-Identifier: Apache-2.0 +import { Interceptor, BeforeRequestParams } from "@open-pioneer/http"; +import { createLogger } from "@open-pioneer/core"; + +const LOG = createLogger("http-app:ExampleInterceptor"); + +export class ExampleInterceptor implements Interceptor { + async beforeRequest?(params: BeforeRequestParams) { + LOG.info("interceptor invoked with", params); + // adds an example query parameter to every request (see browser's network log) + params.target.searchParams.set("a", "b"); + } +} diff --git a/src/samples/http-sample/http-app/build.config.mjs b/src/samples/http-sample/http-app/build.config.mjs index b3936709..f46cb084 100644 --- a/src/samples/http-sample/http-app/build.config.mjs +++ b/src/samples/http-sample/http-app/build.config.mjs @@ -10,6 +10,9 @@ export default defineBuildConfig({ http: "http.HttpService" }, provides: "http-app.HttpClient" + }, + ExampleInterceptor: { + provides: "http.Interceptor" } }, ui: { diff --git a/src/samples/http-sample/http-app/package.json b/src/samples/http-sample/http-app/package.json index 3da77313..b868e425 100644 --- a/src/samples/http-sample/http-app/package.json +++ b/src/samples/http-sample/http-app/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "@open-pioneer/chakra-integration": "workspace:^", + "@open-pioneer/core": "workspace:^", "@open-pioneer/http": "workspace:^", "@open-pioneer/runtime": "workspace:^", "react-json-view": "^1.21.3" diff --git a/src/samples/http-sample/http-app/services.ts b/src/samples/http-sample/http-app/services.ts index b9a1389a..3ba7d95d 100644 --- a/src/samples/http-sample/http-app/services.ts +++ b/src/samples/http-sample/http-app/services.ts @@ -1,3 +1,4 @@ // SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) // SPDX-License-Identifier: Apache-2.0 export { HttpClient } from "./HttpClient"; +export { ExampleInterceptor } from "./ExampleInterceptor"; From 768d3d08f7e598fd4d617f01c877d75518a6482e Mon Sep 17 00:00:00 2001 From: Michael Beckemeyer Date: Mon, 18 Dec 2023 17:13:03 +0100 Subject: [PATCH 2/4] Mark API as experimental --- .changeset/young-wolves-clap.md | 4 +++- src/packages/http/README.md | 2 ++ src/packages/http/api.ts | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.changeset/young-wolves-clap.md b/.changeset/young-wolves-clap.md index 3216edc2..2cbf211c 100644 --- a/.changeset/young-wolves-clap.md +++ b/.changeset/young-wolves-clap.md @@ -2,8 +2,10 @@ "@open-pioneer/http": minor --- -New feature: request interceptors. +New **experimental** feature: request interceptors. Request interceptors can be registered with the `HttpService` to modify requests before they are sent to a server. Request interceptors are called automatically by the `HttpService` when they are present as part of the normal request processing. Example use case: adding an access token (query parameter or header) to requests for a certain resource. + +Note that the request interceptor API is experimental: it may change with a new minor release as a response to feedback. diff --git a/src/packages/http/README.md b/src/packages/http/README.md index bdb8040e..2e4f83cc 100644 --- a/src/packages/http/README.md +++ b/src/packages/http/README.md @@ -45,6 +45,8 @@ However, the `HttpService`'s method should always be preferred to take advantage ## Request interceptors +> Note that the request interceptor API is experimental: it may change with a new minor release as a response to feedback. + The `HttpService` supports extension via _request interceptors_. Request interceptors can modify requests (query parameters, headers, etc.) before they are sent to the server. diff --git a/src/packages/http/api.ts b/src/packages/http/api.ts index d56a1c7a..93c1d9dc 100644 --- a/src/packages/http/api.ts +++ b/src/packages/http/api.ts @@ -78,6 +78,8 @@ export type ContextData = Record; * or to manipulate a backend response. * * Use the interface name `http.Interceptor` to provide an implementation of this interface. + * + * > Note that the request interceptor API is experimental: it may change with a new minor release as a response to feedback. */ export interface Interceptor extends DeclaredService<"http.Interceptor"> { beforeRequest?(params: BeforeRequestParams): void | Promise; From 5777a8238292163a5f37c7e09c5ae12bb81ba9a2 Mon Sep 17 00:00:00 2001 From: Michael Beckemeyer Date: Tue, 19 Dec 2023 16:07:13 +0100 Subject: [PATCH 3/4] Add more documentation --- src/packages/http/api.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/packages/http/api.ts b/src/packages/http/api.ts index 93c1d9dc..2228ad57 100644 --- a/src/packages/http/api.ts +++ b/src/packages/http/api.ts @@ -12,13 +12,14 @@ export interface HttpService extends DeclaredService<"http.HttpService"> { /** * Requests the given `resource` via HTTP and returns the response. * - * See [fetch documentation](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) for reference. + * This method works almost exactly the same as the browser's native `fetch` function. + * However, certain trails extensions (such as interceptors) are implemented on top of `fetch` + * to enable new features. * - * > NOTE: In its current implementation this is a simple wrapper around the browser's fetch. - * > Future versions may implement additional features on top of `fetch()` (such as support for a proxy backend). - * > - * > This service should still be used (instead of plain fetch) to automatically benefit from - * > future developments. + * For example, access tokens or other header / query parameters can be added automatically using an + * interceptor if a package uses the `HttpService`. + * + * See also [fetch documentation](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) for reference. */ fetch(resource: string | URL, init?: HttpServiceRequestInit): Promise; } @@ -42,6 +43,7 @@ export type ResolvedRequestOptions = Omit< headers: Headers; }; +/** Options passed to {@link Interceptor.beforeRequest}. */ export interface BeforeRequestParams { /** * The request's target URL, including query parameters. @@ -82,5 +84,17 @@ export type ContextData = Record; * > Note that the request interceptor API is experimental: it may change with a new minor release as a response to feedback. */ export interface Interceptor extends DeclaredService<"http.Interceptor"> { + /** + * This method will be invoked for every request made by the {@link HttpService}. + * + * The `params` passed to the interceptor method can be inspected and can updated to change how to request is going to be made. + * For example, `target` and `options.headers` can be modified. + * + * The method implementation can be asynchronous. + * + * > NOTE: There may be more than one interceptor in an application. + * > All interceptors are invoked for every request. + * > The order in which the interceptors are invoked is currently not defined. + */ beforeRequest?(params: BeforeRequestParams): void | Promise; } From 527139bcb692a2e1bfd0c96e5f4a4f1aec4e26b5 Mon Sep 17 00:00:00 2001 From: Michael Beckemeyer Date: Wed, 20 Dec 2023 17:13:59 +0100 Subject: [PATCH 4/4] Update api.ts --- src/packages/http/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/packages/http/api.ts b/src/packages/http/api.ts index 2228ad57..f93d8e64 100644 --- a/src/packages/http/api.ts +++ b/src/packages/http/api.ts @@ -87,7 +87,7 @@ export interface Interceptor extends DeclaredService<"http.Interceptor"> { /** * This method will be invoked for every request made by the {@link HttpService}. * - * The `params` passed to the interceptor method can be inspected and can updated to change how to request is going to be made. + * The `params` passed to the interceptor method can be inspected and can also be updated to change how the request is going to be made. * For example, `target` and `options.headers` can be modified. * * The method implementation can be asynchronous.