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:
parent
07d16ff43c
commit
ebd364e392
17 changed files with 257 additions and 37 deletions
19
.changeset/fifty-ads-march.md
Normal file
19
.changeset/fifty-ads-march.md
Normal 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
|
14
.changeset/selfish-foxes-bake.md
Normal file
14
.changeset/selfish-foxes-bake.md
Normal 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);
|
||||
---
|
||||
```
|
|
@ -94,7 +94,8 @@ export interface BuildConfig {
|
|||
* [Astro reference](https://docs.astro.build/reference/api-reference/#astro-global)
|
||||
*/
|
||||
export interface AstroGlobal<Props extends Record<string, any> = Record<string, any>>
|
||||
extends AstroGlobalPartial {
|
||||
extends AstroGlobalPartial,
|
||||
AstroSharedContext<Props> {
|
||||
/**
|
||||
* Canonical URL of the current page.
|
||||
* @deprecated Use `Astro.url` instead.
|
||||
|
@ -107,21 +108,13 @@ export interface AstroGlobal<Props extends Record<string, any> = Record<string,
|
|||
* ```
|
||||
*/
|
||||
canonicalURL: URL;
|
||||
/** The address (usually IP address) of the user. Used with SSR only.
|
||||
*
|
||||
*/
|
||||
clientAddress: string;
|
||||
/**
|
||||
* A full URL object of the request URL.
|
||||
* Equivalent to: `new URL(Astro.request.url)`
|
||||
*
|
||||
* [Astro reference](https://docs.astro.build/en/reference/api-reference/#url)
|
||||
*/
|
||||
/**
|
||||
* Utility for getting and setting cookies values.
|
||||
*/
|
||||
cookies: AstroCookies;
|
||||
url: URL;
|
||||
url: AstroSharedContext['url'];
|
||||
/** Parameters passed to a dynamic page generated using [getStaticPaths](https://docs.astro.build/en/reference/api-reference/#getstaticpaths)
|
||||
*
|
||||
* Example usage:
|
||||
|
@ -138,9 +131,9 @@ export interface AstroGlobal<Props extends Record<string, any> = Record<string,
|
|||
* <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
|
||||
*
|
||||
* 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)
|
||||
*/
|
||||
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
|
||||
*
|
||||
* 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)
|
||||
*/
|
||||
redirect(path: string): Response;
|
||||
redirect: AstroSharedContext['redirect'];
|
||||
/**
|
||||
* 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;
|
||||
/** 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<string, string | number | undefined>;
|
||||
|
||||
export type Props = Record<string, unknown>;
|
||||
|
||||
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<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;
|
||||
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<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 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>['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 {
|
||||
body: Body;
|
||||
encoding?: BufferEncoding;
|
||||
|
|
|
@ -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';
|
||||
|
|
2
packages/astro/src/core/constants.ts
Normal file
2
packages/astro/src/core/constants.ts
Normal 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';
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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<string, any>;
|
||||
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) {
|
||||
|
|
|
@ -68,7 +68,7 @@ export async function getParamsAndProps(
|
|||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
adapterName: string | undefined;
|
||||
adapterName?: string;
|
||||
logging: LogOptions;
|
||||
links: Set<SSRElement>;
|
||||
styles?: Set<SSRElement>;
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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<string, any> {
|
||||
return typeof value === 'object' && value != null;
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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() {
|
||||
|
|
8
packages/astro/test/fixtures/ssr-api-route/astro.config.mjs
vendored
Normal file
8
packages/astro/test/fixtures/ssr-api-route/astro.config.mjs
vendored
Normal 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(),
|
||||
});
|
|
@ -3,6 +3,7 @@
|
|||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^1.1.0",
|
||||
"astro": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
|
18
packages/astro/test/fixtures/ssr-api-route/src/pages/context/[param].js
vendored
Normal file
18
packages/astro/test/fixtures/ssr-api-route/src/pages/context/[param].js
vendored
Normal 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,
|
||||
})
|
||||
};
|
||||
}
|
|
@ -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 () => {
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in a new issue