From ebd364e392035b379dd00b8f2f15a4cc09ee88e6 Mon Sep 17 00:00:00 2001 From: Bjorn Lu Date: Thu, 13 Oct 2022 05:03:49 +0800 Subject: [PATCH] Add new fields to API route context (#4986) * Add new fields to API route context * Add props and redirect * Pass custom redirect status code * Correctly pass props in api routes * Add better docs * Add test * Fix build * Add constants file * Add links to jsdoc * Workaround lint issue * Thanks Chris Co-authored-by: Chris Swithinbank * Missed one doc change Co-authored-by: Chris Swithinbank * Add more detail to the changesets * Strict redirect status type Co-authored-by: Chris Swithinbank Co-authored-by: Matthew Phillips --- .changeset/fifty-ads-march.md | 19 +++ .changeset/selfish-foxes-bake.md | 14 ++ packages/astro/src/@types/astro.ts | 130 +++++++++++++++--- packages/astro/src/cli/index.ts | 3 +- packages/astro/src/core/constants.ts | 2 + packages/astro/src/core/endpoint/dev/index.ts | 2 + packages/astro/src/core/endpoint/index.ts | 55 +++++++- packages/astro/src/core/render/core.ts | 2 +- packages/astro/src/core/render/result.ts | 13 +- packages/astro/src/core/util.ts | 3 - packages/astro/src/events/index.ts | 2 +- .../astro/src/runtime/server/astro-global.ts | 4 +- .../fixtures/ssr-api-route/astro.config.mjs | 8 ++ .../test/fixtures/ssr-api-route/package.json | 1 + .../src/pages/context/[param].js | 18 +++ packages/astro/test/ssr-api-route.test.js | 16 +++ pnpm-lock.yaml | 2 + 17 files changed, 257 insertions(+), 37 deletions(-) create mode 100644 .changeset/fifty-ads-march.md create mode 100644 .changeset/selfish-foxes-bake.md create mode 100644 packages/astro/src/core/constants.ts create mode 100644 packages/astro/test/fixtures/ssr-api-route/astro.config.mjs create mode 100644 packages/astro/test/fixtures/ssr-api-route/src/pages/context/[param].js diff --git a/.changeset/fifty-ads-march.md b/.changeset/fifty-ads-march.md new file mode 100644 index 000000000..d06e8fc36 --- /dev/null +++ b/.changeset/fifty-ads-march.md @@ -0,0 +1,19 @@ +--- +'astro': minor +--- + +## New properties for API routes + +In API routes, you can now get the `site`, `generator`, `url`, `clientAddress`, `props`, and `redirect` fields on the APIContext, which is the first parameter passed to an API route. This was done to make the APIContext more closely align with the `Astro` global in .astro pages. + +For example, here's how you might use the `clientAddress`, which is the user's IP address, to selectively allow users. + +```js +export function post({ clientAddress, request, redirect }) { + if(!allowList.has(clientAddress)) { + return redirect('/not-allowed'); + } +} +``` + +Check out the docs for more information on the newly available fields: https://docs.astro.build/en/core-concepts/endpoints/#server-endpoints-api-routes diff --git a/.changeset/selfish-foxes-bake.md b/.changeset/selfish-foxes-bake.md new file mode 100644 index 000000000..9e253388a --- /dev/null +++ b/.changeset/selfish-foxes-bake.md @@ -0,0 +1,14 @@ +--- +'astro': minor +--- + +## Support passing a custom status code for Astro.redirect + +New in this minor is the ability to pass a status code to `Astro.redirect`. By default it uses `302` but now you can pass another code as the second argument: + +```astro +--- +// This page was moved +return Astro.redirect('/posts/new-post-name', 301); +--- +``` diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index b95aae047..16b5b5226 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -94,7 +94,8 @@ export interface BuildConfig { * [Astro reference](https://docs.astro.build/reference/api-reference/#astro-global) */ export interface AstroGlobal = Record> - extends AstroGlobalPartial { + extends AstroGlobalPartial, + AstroSharedContext { /** * Canonical URL of the current page. * @deprecated Use `Astro.url` instead. @@ -107,21 +108,13 @@ export interface AstroGlobal = Record = Record{id} * ``` * - * [Astro reference](https://docs.astro.build/en/reference/api-reference/#params) + * [Astro reference](https://docs.astro.build/en/reference/api-reference/#astroparams) */ - params: Params; + params: AstroSharedContext['params']; /** List of props passed to this component * * A common way to get specific props is through [destructuring](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment), ex: @@ -150,7 +143,7 @@ export interface AstroGlobal = Record['props']; /** Information about the current request. This is a standard [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object * * For example, to get a URL object of the current URL, you can use: @@ -184,11 +177,11 @@ export interface AstroGlobal = Record element allows a component to reference itself recursively. * - * [Astro reference](https://docs.astro.build/en/guides/server-side-rendering/#astroself) + * [Astro reference](https://docs.astro.build/en/guides/api-reference/#astroself) */ self: AstroComponentFactory; /** Utility functions for modifying an Astro component’s slotted children @@ -1077,8 +1070,6 @@ export type PaginateFunction = (data: any[], args?: PaginateOptions) => GetStati export type Params = Record; -export type Props = Record; - export interface AstroAdapter { name: string; serverEntrypoint?: string; @@ -1088,12 +1079,113 @@ export interface AstroAdapter { type Body = string; -export interface APIContext { +// Shared types between `Astro` global and API context object +interface AstroSharedContext = Record> { + /** + * The address (usually IP address) of the user. Used with SSR only. + */ + clientAddress: string; + /** + * Utility for getting and setting the values of cookies. + */ cookies: AstroCookies; - params: Params; + /** + * Information about the current request. This is a standard [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object + */ request: Request; + /** + * A full URL object of the request URL. + */ + url: URL; + /** + * Route parameters for this request if this is a dynamic route. + */ + params: Params; + /** + * List of props returned for this path by `getStaticPaths` (**Static Only**). + */ + props: Props; + /** + * Redirect to another page (**SSR Only**). + */ + redirect(path: string, status?: 301 | 302 | 308): Response; } +export interface APIContext = Record> + extends AstroSharedContext { + site: URL | undefined; + generator: string; + /** + * A full URL object of the request URL. + * Equivalent to: `new URL(request.url)` + */ + url: AstroSharedContext['url']; + /** + * Parameters matching the page’s dynamic route pattern. + * In static builds, this will be the `params` generated by `getStaticPaths`. + * In SSR builds, this can be any path segments matching the dynamic route pattern. + * + * Example usage: + * ```ts + * export function getStaticPaths() { + * return [ + * { params: { id: '0' }, props: { name: 'Sarah' } }, + * { params: { id: '1' }, props: { name: 'Chris' } }, + * { params: { id: '2' }, props: { name: 'Fuzzy' } }, + * ]; + * } + * + * export async function get({ params }) { + * return { + * body: `Hello user ${params.id}!`, + * } + * } + * ``` + * + * [context reference](https://docs.astro.build/en/guides/api-reference/#contextparams) + */ + params: AstroSharedContext['params']; + /** + * List of props passed from `getStaticPaths`. Only available to static builds. + * + * Example usage: + * ```ts + * export function getStaticPaths() { + * return [ + * { params: { id: '0' }, props: { name: 'Sarah' } }, + * { params: { id: '1' }, props: { name: 'Chris' } }, + * { params: { id: '2' }, props: { name: 'Fuzzy' } }, + * ]; + * } + * + * export function get({ props }) { + * return { + * body: `Hello ${props.name}!`, + * } + * } + * ``` + * + * [context reference](https://docs.astro.build/en/guides/api-reference/#contextprops) + */ + props: AstroSharedContext['props']; + /** + * Redirect to another page. Only available in SSR builds. + * + * Example usage: + * ```ts + * // src/pages/secret.ts + * export function get({ redirect }) { + * return redirect('/login'); + * } + * ``` + * + * [context reference](https://docs.astro.build/en/guides/api-reference/#contextredirect) + */ + redirect: AstroSharedContext['redirect']; +} + +export type Props = Record; + export interface EndpointOutput { body: Body; encoding?: BufferEncoding; diff --git a/packages/astro/src/cli/index.ts b/packages/astro/src/cli/index.ts index ab52f6415..18910e154 100644 --- a/packages/astro/src/cli/index.ts +++ b/packages/astro/src/cli/index.ts @@ -21,7 +21,8 @@ import { enableVerboseLogging, nodeLogDestination } from '../core/logger/node.js import { formatConfigErrorMessage, formatErrorMessage, printHelp } from '../core/messages.js'; import { appendForwardSlash } from '../core/path.js'; import preview from '../core/preview/index.js'; -import { ASTRO_VERSION, createSafeError } from '../core/util.js'; +import { ASTRO_VERSION } from '../core/constants.js'; +import { createSafeError } from '../core/util.js'; import * as event from '../events/index.js'; import { eventConfigError, eventError, telemetry } from '../events/index.js'; import { check } from './check/index.js'; diff --git a/packages/astro/src/core/constants.ts b/packages/astro/src/core/constants.ts new file mode 100644 index 000000000..15c5d34e4 --- /dev/null +++ b/packages/astro/src/core/constants.ts @@ -0,0 +1,2 @@ +// process.env.PACKAGE_VERSION is injected when we build and publish the astro package. +export const ASTRO_VERSION = process.env.PACKAGE_VERSION ?? 'development'; diff --git a/packages/astro/src/core/endpoint/dev/index.ts b/packages/astro/src/core/endpoint/dev/index.ts index b2f16225b..b27127119 100644 --- a/packages/astro/src/core/endpoint/dev/index.ts +++ b/packages/astro/src/core/endpoint/dev/index.ts @@ -8,5 +8,7 @@ export async function call(ssrOpts: SSROptions) { return await callEndpoint(mod as unknown as EndpointHandler, { ...ssrOpts, ssr: ssrOpts.settings.config.output === 'server', + site: ssrOpts.settings.config.site, + adapterName: ssrOpts.settings.config.adapter?.name, }); } diff --git a/packages/astro/src/core/endpoint/index.ts b/packages/astro/src/core/endpoint/index.ts index 9f1adb655..3df01af25 100644 --- a/packages/astro/src/core/endpoint/index.ts +++ b/packages/astro/src/core/endpoint/index.ts @@ -4,6 +4,9 @@ import type { RenderOptions } from '../render/core'; import { renderEndpoint } from '../../runtime/server/index.js'; import { AstroCookies, attachToResponse } from '../cookies/index.js'; import { getParamsAndProps, GetParamsAndPropsError } from '../render/core.js'; +import { ASTRO_VERSION } from '../constants.js'; + +const clientAddressSymbol = Symbol.for('astro.clientAddress'); export type EndpointOptions = Pick< RenderOptions, @@ -17,6 +20,7 @@ export type EndpointOptions = Pick< | 'site' | 'ssr' | 'status' + | 'adapterName' >; type EndpointCallResult = @@ -30,11 +34,50 @@ type EndpointCallResult = response: Response; }; -function createAPIContext(request: Request, params: Params): APIContext { +function createAPIContext({ + request, + params, + site, + props, + adapterName, +}: { + request: Request; + params: Params; + site?: string; + props: Record; + adapterName?: string; +}): APIContext { return { cookies: new AstroCookies(request), request, params, + site: site ? new URL(site) : undefined, + generator: `Astro v${ASTRO_VERSION}`, + props, + redirect(path, status) { + return new Response(null, { + status: status || 302, + headers: { + Location: path, + }, + }); + }, + url: new URL(request.url), + get clientAddress() { + if (!(clientAddressSymbol in request)) { + if (adapterName) { + throw new Error( + `clientAddress is not available in the ${adapterName} adapter. File an issue with the adapter to add support.` + ); + } else { + throw new Error( + `clientAddress is not available in your environment. Ensure that you are using an SSR adapter that supports this feature.` + ); + } + } + + return Reflect.get(request, clientAddressSymbol); + }, }; } @@ -49,9 +92,15 @@ export async function call( `[getStaticPath] route pattern matched, but no matching static path found. (${opts.pathname})` ); } - const [params] = paramsAndPropsResp; + const [params, props] = paramsAndPropsResp; - const context = createAPIContext(opts.request, params); + const context = createAPIContext({ + request: opts.request, + params, + props, + site: opts.site, + adapterName: opts.adapterName, + }); const response = await renderEndpoint(mod, context, opts.ssr); if (response instanceof Response) { diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts index bbbb6c53f..5b7a3122a 100644 --- a/packages/astro/src/core/render/core.ts +++ b/packages/astro/src/core/render/core.ts @@ -68,7 +68,7 @@ export async function getParamsAndProps( } export interface RenderOptions { - adapterName: string | undefined; + adapterName?: string; logging: LogOptions; links: Set; styles?: Set; diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts index f0cd02c54..a87ea54c7 100644 --- a/packages/astro/src/core/render/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -166,7 +166,8 @@ export function createResult(args: CreateResultArgs): SSRResult { ) { const astroSlots = new Slots(result, slots, args.logging); - const Astro = { + const Astro: AstroGlobal = { + // @ts-expect-error set prototype __proto__: astroGlobal, get clientAddress() { if (!(clientAddressSymbol in request)) { @@ -196,9 +197,9 @@ export function createResult(args: CreateResultArgs): SSRResult { request, url, redirect: args.ssr - ? (path: string) => { + ? (path, status) => { return new Response(null, { - status: 302, + status: status || 302, headers: { Location: path, }, @@ -237,9 +238,9 @@ ${extra}` // Intentionally return an empty string so that it is not relied upon. return ''; }, - response, - slots: astroSlots, - } as unknown as AstroGlobal; + response: response as AstroGlobal['response'], + slots: astroSlots as unknown as AstroGlobal['slots'], + }; Object.defineProperty(Astro, 'canonicalURL', { get: function () { diff --git a/packages/astro/src/core/util.ts b/packages/astro/src/core/util.ts index ed049ff41..4c1d387cb 100644 --- a/packages/astro/src/core/util.ts +++ b/packages/astro/src/core/util.ts @@ -8,9 +8,6 @@ import type { ErrorPayload, ViteDevServer } from 'vite'; import type { AstroConfig, AstroSettings, RouteType } from '../@types/astro'; import { prependForwardSlash, removeTrailingForwardSlash } from './path.js'; -// process.env.PACKAGE_VERSION is injected when we build and publish the astro package. -export const ASTRO_VERSION = process.env.PACKAGE_VERSION ?? 'development'; - /** Returns true if argument is an object of any prototype/class (but not null). */ export function isObject(value: unknown): value is Record { return typeof value === 'object' && value != null; diff --git a/packages/astro/src/events/index.ts b/packages/astro/src/events/index.ts index 784e7723f..31e549ad7 100644 --- a/packages/astro/src/events/index.ts +++ b/packages/astro/src/events/index.ts @@ -1,6 +1,6 @@ import { AstroTelemetry } from '@astrojs/telemetry'; import { createRequire } from 'module'; -import { ASTRO_VERSION } from '../core/util.js'; +import { ASTRO_VERSION } from '../core/constants.js'; const require = createRequire(import.meta.url); function getViteVersion() { diff --git a/packages/astro/src/runtime/server/astro-global.ts b/packages/astro/src/runtime/server/astro-global.ts index 5ffca377a..101ec53ac 100644 --- a/packages/astro/src/runtime/server/astro-global.ts +++ b/packages/astro/src/runtime/server/astro-global.ts @@ -1,7 +1,5 @@ import type { AstroGlobalPartial } from '../../@types/astro'; - -// process.env.PACKAGE_VERSION is injected when we build and publish the astro package. -const ASTRO_VERSION = process.env.PACKAGE_VERSION ?? 'development'; +import { ASTRO_VERSION } from '../../core/constants.js'; /** Create the Astro.fetchContent() runtime function. */ function createDeprecatedFetchContentFn() { diff --git a/packages/astro/test/fixtures/ssr-api-route/astro.config.mjs b/packages/astro/test/fixtures/ssr-api-route/astro.config.mjs new file mode 100644 index 000000000..ef9763e01 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-api-route/astro.config.mjs @@ -0,0 +1,8 @@ +import { defineConfig } from 'astro/config'; +import node from '@astrojs/node'; + +// https://astro.build/config +export default defineConfig({ + output: 'server', + adapter: node(), +}); diff --git a/packages/astro/test/fixtures/ssr-api-route/package.json b/packages/astro/test/fixtures/ssr-api-route/package.json index f31c4aeee..942034f45 100644 --- a/packages/astro/test/fixtures/ssr-api-route/package.json +++ b/packages/astro/test/fixtures/ssr-api-route/package.json @@ -3,6 +3,7 @@ "version": "0.0.0", "private": true, "dependencies": { + "@astrojs/node": "^1.1.0", "astro": "workspace:*" } } diff --git a/packages/astro/test/fixtures/ssr-api-route/src/pages/context/[param].js b/packages/astro/test/fixtures/ssr-api-route/src/pages/context/[param].js new file mode 100644 index 000000000..0ff1f625a --- /dev/null +++ b/packages/astro/test/fixtures/ssr-api-route/src/pages/context/[param].js @@ -0,0 +1,18 @@ +/** + * @param {import('astro').APIContext} api + */ +export function get(ctx) { + return { + body: JSON.stringify({ + cookiesExist: !!ctx.cookies, + requestExist: !!ctx.request, + redirectExist: !!ctx.redirect, + propsExist: !!ctx.props, + params: ctx.params, + site: ctx.site?.toString(), + generator: ctx.generator, + url: ctx.url.toString(), + clientAddress: ctx.clientAddress, + }) + }; +} diff --git a/packages/astro/test/ssr-api-route.test.js b/packages/astro/test/ssr-api-route.test.js index 1409657c3..33b1ffdab 100644 --- a/packages/astro/test/ssr-api-route.test.js +++ b/packages/astro/test/ssr-api-route.test.js @@ -35,6 +35,22 @@ describe('API routes in SSR', () => { expect(body.length).to.equal(3); }); + it('Has valid api context', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/context/any'); + const response = await app.render(request); + expect(response.status).to.equal(200); + const data = await response.json(); + expect(data.cookiesExist).to.equal(true); + expect(data.requestExist).to.equal(true); + expect(data.redirectExist).to.equal(true); + expect(data.propsExist).to.equal(true); + expect(data.params).to.deep.equal({ param: 'any' }); + expect(data.generator).to.match(/^Astro v/); + expect(data.url).to.equal('http://example.com/context/any'); + expect(data.clientAddress).to.equal('0.0.0.0'); + }); + describe('API Routes - Dev', () => { let devServer; before(async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9258f1bb..ae9834b3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2161,8 +2161,10 @@ importers: packages/astro/test/fixtures/ssr-api-route: specifiers: + '@astrojs/node': ^1.1.0 astro: workspace:* dependencies: + '@astrojs/node': link:../../../../integrations/node astro: link:../../.. packages/astro/test/fixtures/ssr-api-route-custom-404: