From 9df7574094a0c1ac69322b46f7429b8be64e101f Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 15 Sep 2026 21:44:54 -0400 Subject: [PATCH] Use native Request objects for signing examples --- docs/cloud/headless-apps.md | 62 +++++++++++++++----------------- docs/cloud/request-signing.md | 68 +++++++++++++++++------------------ 2 files changed, 61 insertions(+), 69 deletions(-) diff --git a/docs/cloud/headless-apps.md b/docs/cloud/headless-apps.md index 7a395ed96..c06da8580 100644 --- a/docs/cloud/headless-apps.md +++ b/docs/cloud/headless-apps.md @@ -63,7 +63,7 @@ function getRetryDelay(response, attempt) { return backoff; } -export async function fetchWithRetry(input, init = {}) { +export async function fetchWithRetry(request) { const deadline = Date.now() + TOTAL_TIMEOUT; for (let attempt = 0; ; attempt++) { @@ -73,11 +73,11 @@ export async function fetchWithRetry(input, init = {}) { throw new Error('Craft request timed out'); } - const timeoutSignal = AbortSignal.timeout(remaining); - const signal = init.signal - ? AbortSignal.any([init.signal, timeoutSignal]) - : timeoutSignal; - const response = await fetch(input, { ...init, signal }); + const signal = AbortSignal.any([ + request.signal, + AbortSignal.timeout(remaining), + ]); + const response = await fetch(request.clone(), { signal }); if (response.ok) { return response; @@ -105,8 +105,8 @@ export async function fetchWithRetry(input, init = {}) { Create a `request-signatures.js` module using `getSignatureHeaders()` from the general [Node.js signing example](request-signing.md#from-node-js). The -framework examples below import that helper so signing does not interfere with -framework-specific request options. +framework examples below construct and sign a native `Request` before sending +it. ## Next.js Example @@ -131,14 +131,14 @@ const headers = { const getBlogEntries = unstable_cache( async () => { - const signatureHeaders = getSignatureHeaders({ method, url, headers }); - const result = await ky.post(url, { - body, + const request = new Request(url, { method, body, headers }); + + for (const [name, value] of Object.entries(getSignatureHeaders(request))) { + request.headers.set(name, value); + } + + const result = await ky(request, { cache: 'no-store', - headers: { - ...headers, - ...signatureHeaders, - }, retry: { limit: Number.POSITIVE_INFINITY, methods: ['post'], @@ -197,15 +197,13 @@ const headers = { }; export default defineEventHandler(async () => { - const signatureHeaders = getSignatureHeaders({ method, url, headers }); - const response = await fetchWithRetry(url, { - method, - body, - headers: { - ...headers, - ...signatureHeaders, - }, - }); + const request = new Request(url, { method, body, headers }); + + for (const [name, value] of Object.entries(getSignatureHeaders(request))) { + request.headers.set(name, value); + } + + const response = await fetchWithRetry(request); const result = await response.json(); if (result.errors?.length) { @@ -252,15 +250,13 @@ const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${CRAFT_GRAPHQL_TOKEN}`, }; -const signatureHeaders = getSignatureHeaders({ method, url, headers }); -const response = await fetchWithRetry(url, { - method, - body, - headers: { - ...headers, - ...signatureHeaders, - }, -}); +const request = new Request(url, { method, body, headers }); + +for (const [name, value] of Object.entries(getSignatureHeaders(request))) { + request.headers.set(name, value); +} + +const response = await fetchWithRetry(request); const result = await response.json(); if (result.errors?.length) { diff --git a/docs/cloud/request-signing.md b/docs/cloud/request-signing.md index e810435eb..e5ba33897 100644 --- a/docs/cloud/request-signing.md +++ b/docs/cloud/request-signing.md @@ -49,62 +49,56 @@ export function getSignatureHeaders( ) { const created = new Date(); - return signatureHeadersSync( - request, - { - key: 'sig', - signer: { - keyid: 'hmac', - alg: 'hmac-sha256', - signSync(data) { - return crypto - .createHmac('sha256', CRAFT_CLOUD_SIGNING_KEY) - .update(data) - .digest(); - }, + return signatureHeadersSync(request, { + key: 'sig', + signer: { + keyid: 'hmac', + alg: 'hmac-sha256', + signSync(data) { + return crypto + .createHmac('sha256', CRAFT_CLOUD_SIGNING_KEY) + .update(data) + .digest(); }, - components, - created, - tag: 'craft-cloud', + }, + components, + created, + tag: 'craft-cloud', - // Optional expiry. The maximum is five minutes. - // expires: new Date(created.getTime() + 60 * 1000), - } - ); + // Optional expiry. The maximum is five minutes. + // expires: new Date(created.getTime() + 60 * 1000), + }); } ``` Pass additional [covered components](https://www.rfc-editor.org/rfc/rfc9421.html#name-http-message-components), such as `content-type`, in the second argument when those values must also be signed. -Import the helper when sending a signed request: +Construct a native `Request`, sign it, then send the same object: ```js import { getSignatureHeaders } from './request-signatures.js'; -const request = { - method: 'POST', - url: 'https://my-env.some-domain.com/api', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer my-secret-gql-schema-token', - }, -}; +const url = new URL('https://my-env.some-domain.com/api'); const body = JSON.stringify({ query: `{ entries(section: "blog") { title url } }`, }); -const signatureHeaders = getSignatureHeaders(request); - -const response = await fetch(request.url, { - method: request.method, +const request = new Request(url, { + method: 'POST', headers: { - ...request.headers, - ...signatureHeaders, + 'Content-Type': 'application/json', + 'Authorization': 'Bearer my-secret-gql-schema-token', }, body, }); +for (const [name, value] of Object.entries(getSignatureHeaders(request))) { + request.headers.set(name, value); +} + +const response = await fetch(request); + if (!response.ok) { throw new Error(`Craft request failed: ${response.status}`); } @@ -112,7 +106,9 @@ if (!response.ok) { ::: tip Requests signed using the `@target-uri` [component](https://www.rfc-editor.org/rfc/rfc9421.html#name-derived-components) are only valid when sent to a URL that matches _exactly_, including the scheme, hostname, path, and query string. -The example above satisfies this by using the same `request.url` value for signing and the `fetch()` call. +Constructing, signing, and sending the same `Request` object ensures that the signed URL matches the URL sent by `fetch()`. + +When adding query parameters, use `url.searchParams.set(name, value)` rather than appending raw values. This encodes parameter names and values before the request is signed. ::: ### From Grafana Cloud k6