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 <swithinbank@gmail.com>

* Missed one doc change

Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>

* Add more detail to the changesets

* Strict redirect status type

Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
Co-authored-by: Matthew Phillips <matthew@skypack.dev>
This commit is contained in:
Bjorn Lu 2022-10-13 05:03:49 +08:00 committed by GitHub
parent 07d16ff43c
commit ebd364e392
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 257 additions and 37 deletions

View file

@ -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

View file

@ -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);
---
```

View file

@ -94,7 +94,8 @@ export interface BuildConfig {
* [Astro reference](https://docs.astro.build/reference/api-reference/#astro-global) * [Astro reference](https://docs.astro.build/reference/api-reference/#astro-global)
*/ */
export interface AstroGlobal<Props extends Record<string, any> = Record<string, any>> export interface AstroGlobal<Props extends Record<string, any> = Record<string, any>>
extends AstroGlobalPartial { extends AstroGlobalPartial,
AstroSharedContext<Props> {
/** /**
* Canonical URL of the current page. * Canonical URL of the current page.
* @deprecated Use `Astro.url` instead. * @deprecated Use `Astro.url` instead.
@ -107,21 +108,13 @@ export interface AstroGlobal<Props extends Record<string, any> = Record<string,
* ``` * ```
*/ */
canonicalURL: URL; canonicalURL: URL;
/** The address (usually IP address) of the user. Used with SSR only.
*
*/
clientAddress: string;
/** /**
* A full URL object of the request URL. * A full URL object of the request URL.
* Equivalent to: `new URL(Astro.request.url)` * Equivalent to: `new URL(Astro.request.url)`
* *
* [Astro reference](https://docs.astro.build/en/reference/api-reference/#url) * [Astro reference](https://docs.astro.build/en/reference/api-reference/#url)
*/ */
/** url: AstroSharedContext['url'];
* Utility for getting and setting cookies values.
*/
cookies: AstroCookies;
url: URL;
/** Parameters passed to a dynamic page generated using [getStaticPaths](https://docs.astro.build/en/reference/api-reference/#getstaticpaths) /** Parameters passed to a dynamic page generated using [getStaticPaths](https://docs.astro.build/en/reference/api-reference/#getstaticpaths)
* *
* Example usage: * Example usage:
@ -138,9 +131,9 @@ export interface AstroGlobal<Props extends Record<string, any> = Record<string,
* <h1>{id}</h1> * <h1>{id}</h1>
* ``` * ```
* *
* [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 /** 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: * 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<Props extends Record<string, any> = Record<string,
* *
* [Astro reference](https://docs.astro.build/en/core-concepts/astro-components/#component-props) * [Astro reference](https://docs.astro.build/en/core-concepts/astro-components/#component-props)
*/ */
props: Props; props: AstroSharedContext<Props>['props'];
/** Information about the current request. This is a standard [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object /** 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: * For example, to get a URL object of the current URL, you can use:
@ -184,11 +177,11 @@ export interface AstroGlobal<Props extends Record<string, any> = Record<string,
* *
* [Astro reference](https://docs.astro.build/en/guides/server-side-rendering/#astroredirect) * [Astro reference](https://docs.astro.build/en/guides/server-side-rendering/#astroredirect)
*/ */
redirect(path: string): Response; redirect: AstroSharedContext['redirect'];
/** /**
* The <Astro.self /> element allows a component to reference itself recursively. * The <Astro.self /> 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; self: AstroComponentFactory;
/** Utility functions for modifying an Astro components slotted children /** Utility functions for modifying an Astro components slotted children
@ -1077,8 +1070,6 @@ export type PaginateFunction = (data: any[], args?: PaginateOptions) => GetStati
export type Params = Record<string, string | number | undefined>; export type Params = Record<string, string | number | undefined>;
export type Props = Record<string, unknown>;
export interface AstroAdapter { export interface AstroAdapter {
name: string; name: string;
serverEntrypoint?: string; serverEntrypoint?: string;
@ -1088,12 +1079,113 @@ export interface AstroAdapter {
type Body = string; type Body = string;
export interface APIContext { // Shared types between `Astro` global and API context object
interface AstroSharedContext<Props extends Record<string, any> = Record<string, any>> {
/**
* 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; 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; 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<Props extends Record<string, any> = Record<string, any>>
extends AstroSharedContext<Props> {
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 pages 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>['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<string, unknown>;
export interface EndpointOutput { export interface EndpointOutput {
body: Body; body: Body;
encoding?: BufferEncoding; encoding?: BufferEncoding;

View file

@ -21,7 +21,8 @@ import { enableVerboseLogging, nodeLogDestination } from '../core/logger/node.js
import { formatConfigErrorMessage, formatErrorMessage, printHelp } from '../core/messages.js'; import { formatConfigErrorMessage, formatErrorMessage, printHelp } from '../core/messages.js';
import { appendForwardSlash } from '../core/path.js'; import { appendForwardSlash } from '../core/path.js';
import preview from '../core/preview/index.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 * as event from '../events/index.js';
import { eventConfigError, eventError, telemetry } from '../events/index.js'; import { eventConfigError, eventError, telemetry } from '../events/index.js';
import { check } from './check/index.js'; import { check } from './check/index.js';

View file

@ -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';

View file

@ -8,5 +8,7 @@ export async function call(ssrOpts: SSROptions) {
return await callEndpoint(mod as unknown as EndpointHandler, { return await callEndpoint(mod as unknown as EndpointHandler, {
...ssrOpts, ...ssrOpts,
ssr: ssrOpts.settings.config.output === 'server', ssr: ssrOpts.settings.config.output === 'server',
site: ssrOpts.settings.config.site,
adapterName: ssrOpts.settings.config.adapter?.name,
}); });
} }

View file

@ -4,6 +4,9 @@ import type { RenderOptions } from '../render/core';
import { renderEndpoint } from '../../runtime/server/index.js'; import { renderEndpoint } from '../../runtime/server/index.js';
import { AstroCookies, attachToResponse } from '../cookies/index.js'; import { AstroCookies, attachToResponse } from '../cookies/index.js';
import { getParamsAndProps, GetParamsAndPropsError } from '../render/core.js'; import { getParamsAndProps, GetParamsAndPropsError } from '../render/core.js';
import { ASTRO_VERSION } from '../constants.js';
const clientAddressSymbol = Symbol.for('astro.clientAddress');
export type EndpointOptions = Pick< export type EndpointOptions = Pick<
RenderOptions, RenderOptions,
@ -17,6 +20,7 @@ export type EndpointOptions = Pick<
| 'site' | 'site'
| 'ssr' | 'ssr'
| 'status' | 'status'
| 'adapterName'
>; >;
type EndpointCallResult = type EndpointCallResult =
@ -30,11 +34,50 @@ type EndpointCallResult =
response: Response; response: Response;
}; };
function createAPIContext(request: Request, params: Params): APIContext { function createAPIContext({
request,
params,
site,
props,
adapterName,
}: {
request: Request;
params: Params;
site?: string;
props: Record<string, any>;
adapterName?: string;
}): APIContext {
return { return {
cookies: new AstroCookies(request), cookies: new AstroCookies(request),
request, request,
params, 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})` `[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); const response = await renderEndpoint(mod, context, opts.ssr);
if (response instanceof Response) { if (response instanceof Response) {

View file

@ -68,7 +68,7 @@ export async function getParamsAndProps(
} }
export interface RenderOptions { export interface RenderOptions {
adapterName: string | undefined; adapterName?: string;
logging: LogOptions; logging: LogOptions;
links: Set<SSRElement>; links: Set<SSRElement>;
styles?: Set<SSRElement>; styles?: Set<SSRElement>;

View file

@ -166,7 +166,8 @@ export function createResult(args: CreateResultArgs): SSRResult {
) { ) {
const astroSlots = new Slots(result, slots, args.logging); const astroSlots = new Slots(result, slots, args.logging);
const Astro = { const Astro: AstroGlobal = {
// @ts-expect-error set prototype
__proto__: astroGlobal, __proto__: astroGlobal,
get clientAddress() { get clientAddress() {
if (!(clientAddressSymbol in request)) { if (!(clientAddressSymbol in request)) {
@ -196,9 +197,9 @@ export function createResult(args: CreateResultArgs): SSRResult {
request, request,
url, url,
redirect: args.ssr redirect: args.ssr
? (path: string) => { ? (path, status) => {
return new Response(null, { return new Response(null, {
status: 302, status: status || 302,
headers: { headers: {
Location: path, Location: path,
}, },
@ -237,9 +238,9 @@ ${extra}`
// Intentionally return an empty string so that it is not relied upon. // Intentionally return an empty string so that it is not relied upon.
return ''; return '';
}, },
response, response: response as AstroGlobal['response'],
slots: astroSlots, slots: astroSlots as unknown as AstroGlobal['slots'],
} as unknown as AstroGlobal; };
Object.defineProperty(Astro, 'canonicalURL', { Object.defineProperty(Astro, 'canonicalURL', {
get: function () { get: function () {

View file

@ -8,9 +8,6 @@ import type { ErrorPayload, ViteDevServer } from 'vite';
import type { AstroConfig, AstroSettings, RouteType } from '../@types/astro'; import type { AstroConfig, AstroSettings, RouteType } from '../@types/astro';
import { prependForwardSlash, removeTrailingForwardSlash } from './path.js'; 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). */ /** Returns true if argument is an object of any prototype/class (but not null). */
export function isObject(value: unknown): value is Record<string, any> { export function isObject(value: unknown): value is Record<string, any> {
return typeof value === 'object' && value != null; return typeof value === 'object' && value != null;

View file

@ -1,6 +1,6 @@
import { AstroTelemetry } from '@astrojs/telemetry'; import { AstroTelemetry } from '@astrojs/telemetry';
import { createRequire } from 'module'; import { createRequire } from 'module';
import { ASTRO_VERSION } from '../core/util.js'; import { ASTRO_VERSION } from '../core/constants.js';
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
function getViteVersion() { function getViteVersion() {

View file

@ -1,7 +1,5 @@
import type { AstroGlobalPartial } from '../../@types/astro'; import type { AstroGlobalPartial } from '../../@types/astro';
import { ASTRO_VERSION } from '../../core/constants.js';
// process.env.PACKAGE_VERSION is injected when we build and publish the astro package.
const ASTRO_VERSION = process.env.PACKAGE_VERSION ?? 'development';
/** Create the Astro.fetchContent() runtime function. */ /** Create the Astro.fetchContent() runtime function. */
function createDeprecatedFetchContentFn() { function createDeprecatedFetchContentFn() {

View file

@ -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(),
});

View file

@ -3,6 +3,7 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@astrojs/node": "^1.1.0",
"astro": "workspace:*" "astro": "workspace:*"
} }
} }

View file

@ -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,
})
};
}

View file

@ -35,6 +35,22 @@ describe('API routes in SSR', () => {
expect(body.length).to.equal(3); 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', () => { describe('API Routes - Dev', () => {
let devServer; let devServer;
before(async () => { before(async () => {

View file

@ -2161,8 +2161,10 @@ importers:
packages/astro/test/fixtures/ssr-api-route: packages/astro/test/fixtures/ssr-api-route:
specifiers: specifiers:
'@astrojs/node': ^1.1.0
astro: workspace:* astro: workspace:*
dependencies: dependencies:
'@astrojs/node': link:../../../../integrations/node
astro: link:../../.. astro: link:../../..
packages/astro/test/fixtures/ssr-api-route-custom-404: packages/astro/test/fixtures/ssr-api-route-custom-404: