Skip to content

Commit

Permalink
feat(create-pages): api handling (#1108)
Browse files Browse the repository at this point in the history
This adds api route handling in createPages with tests

---------

Co-authored-by: Tyler <[email protected]>
Co-authored-by: Daishi Kato <[email protected]>
  • Loading branch information
3 people authored Jan 1, 2025
1 parent 74d7e7d commit 058f9a1
Show file tree
Hide file tree
Showing 2 changed files with 206 additions and 13 deletions.
86 changes: 79 additions & 7 deletions packages/waku/src/router/create-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,15 @@ export type CreateLayout = <Path extends string>(
},
) => void;

type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';

export type CreateApi = <Path extends string>(params: {
path: Path;
mode: 'static' | 'dynamic';
method: Method;
handler: (req: Request) => Promise<Response>;
}) => void;

type RootItem = {
render: 'static' | 'dynamic';
component: FunctionComponent<{ children: ReactNode }>;
Expand Down Expand Up @@ -191,6 +200,7 @@ export const createPages = <
createPage: CreatePage;
createLayout: CreateLayout;
createRoot: CreateRoot;
createApi: CreateApi;
}) => Promise<AllPages>,
) => {
let configured = false;
Expand All @@ -211,6 +221,15 @@ export const createPages = <
string,
[PathSpec, FunctionComponent<any>]
>();
const apiPathMap = new Map<
string,
{
mode: 'static' | 'dynamic';
pathSpec: PathSpec;
method: Method;
handler: Parameters<CreateApi>[0]['handler'];
}
>();
const staticComponentMap = new Map<string, FunctionComponent<any>>();
let rootItem: RootItem | undefined = undefined;
const noSsrSet = new WeakSet<PathSpec>();
Expand All @@ -223,6 +242,7 @@ export const createPages = <
const allPaths = [
...dynamicPagePathMap.keys(),
...wildcardPagePathMap.keys(),
...apiPathMap.keys(),
];
for (const p of allPaths) {
if (getPathMapping(parsePathWithSlug(p), path)) {
Expand All @@ -231,6 +251,15 @@ export const createPages = <
}
};

const pathExists = (path: string) => {
return (
staticPathMap.has(path) ||
dynamicPagePathMap.has(path) ||
wildcardPagePathMap.has(path) ||
apiPathMap.has(path)
);
};

/** helper to get original static slug path */
const getOriginalStaticPathSpec = (path: string) => {
const staticPathSpec = staticPathMap.get(path);
Expand All @@ -256,6 +285,9 @@ export const createPages = <
if (configured) {
throw new Error('createPage no longer available');
}
if (pathExists(page.path)) {
throw new Error(`Duplicated path: ${page.path}`);
}

const pathSpec = parsePathWithSlug(page.path);
if (page.unstable_disableSSR) {
Expand Down Expand Up @@ -320,14 +352,8 @@ export const createPages = <
registerStaticComponent(id, WrappedComponent);
}
} else if (page.render === 'dynamic' && numWildcards === 0) {
if (dynamicPagePathMap.has(page.path)) {
throw new Error(`Duplicated dynamic path: ${page.path}`);
}
dynamicPagePathMap.set(page.path, [pathSpec, page.component]);
} else if (page.render === 'dynamic' && numWildcards === 1) {
if (wildcardPagePathMap.has(page.path)) {
throw new Error(`Duplicated dynamic path: ${page.path}`);
}
wildcardPagePathMap.set(page.path, [pathSpec, page.component]);
} else {
throw new Error('Invalid page configuration');
Expand All @@ -353,6 +379,18 @@ export const createPages = <
}
};

const createApi: CreateApi = ({ path, mode, method, handler }) => {
if (configured) {
throw new Error('createApi no longer available');
}
if (apiPathMap.has(path)) {
throw new Error(`Duplicated api path: ${path}`);
}

const pathSpec = parsePathWithSlug(path);
apiPathMap.set(path, { mode, pathSpec, method, handler });
};

const createRoot: CreateRoot = (root) => {
if (configured) {
throw new Error('createRoot no longer available');
Expand All @@ -370,7 +408,7 @@ export const createPages = <
let ready: Promise<AllPages | void> | undefined;
const configure = async () => {
if (!configured && !ready) {
ready = fn({ createPage, createLayout, createRoot });
ready = fn({ createPage, createLayout, createRoot, createApi });
await ready;
configured = true;
}
Expand Down Expand Up @@ -568,6 +606,40 @@ export const createPages = <
),
};
},
getApiConfig: async () => {
await configure();

return Array.from(apiPathMap.values()).map(({ pathSpec, mode }) => {
return {
path: pathSpec,
isStatic: mode === 'static',
};
});
},
handleApi: async (path, options) => {
await configure();
const routePath = getRoutePath(path);
if (!routePath) {
throw new Error('Route not found: ' + path);
}
const { handler } = apiPathMap.get(routePath)!;

const req = new Request(
new URL(
path,
// TODO consider if we should apply `Forwarded` header here
'http://localhost',
),
options,
);
const res = await handler(req);

return {
...(res.body ? { body: res.body } : {}),
headers: Object.fromEntries(res.headers.entries()),
status: res.status,
};
},
});

return definedRouter as typeof definedRouter & {
Expand Down
133 changes: 127 additions & 6 deletions packages/waku/tests/create-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { expect, vi, describe, it, beforeEach, assert } from 'vitest';
import type { MockedFunction } from 'vitest';
import { createPages } from '../src/router/create-pages.js';
import type {
CreateApi,
CreateLayout,
CreatePage,
HasSlugInPath,
Expand Down Expand Up @@ -264,6 +265,59 @@ describe('type tests', () => {
});
});

describe('createApi', () => {
it('static', () => {
const createApi: CreateApi = vi.fn();
// @ts-expect-error: mode is not valid
createApi({ path: '/', mode: 'foo', method: 'GET', handler: () => null });
createApi({
path: '/',
mode: 'static',
// @ts-expect-error: method is not valid
method: 'foo',
// @ts-expect-error: null is not valid
handler: () => null,
});
// @ts-expect-error: handler is not valid
createApi({ path: '/', mode: 'static', method: 'GET', handler: 123 });

// good
createApi({
path: '/',
mode: 'static',
method: 'GET',
handler: async () => {
return new Response('Hello World');
},
});
});
it('dynamic', () => {
const createApi: CreateApi = vi.fn();
// @ts-expect-error: mode & handler are not valid
createApi({ path: '/', mode: 'foo', method: 'GET', handler: () => null });
createApi({
path: '/foo',
mode: 'dynamic',
// @ts-expect-error: method is not valid
method: 'foo',
// @ts-expect-error: null is not valid
handler: () => null,
});
// @ts-expect-error: handler is not valid
createApi({ path: '/', mode: 'dynamic', method: 'GET', handler: 123 });

// good
createApi({
path: '/foo/[slug]',
mode: 'dynamic',
method: 'GET',
handler: async () => {
return new Response('Hello World');
},
});
});
});

describe('createPages', () => {
it('empty', () => {
const mockedCreatePages: typeof createPages = vi.fn();
Expand Down Expand Up @@ -414,9 +468,13 @@ function injectedFunctions() {
expect(defineRouterMock).toHaveBeenCalledTimes(1);
assert(defineRouterMock.mock.calls[0]?.[0].getRouteConfig);
assert(defineRouterMock.mock.calls[0]?.[0].handleRoute);
assert(defineRouterMock.mock.calls[0]?.[0].getApiConfig);
assert(defineRouterMock.mock.calls[0]?.[0].handleApi);
return {
getRouteConfig: defineRouterMock.mock.calls[0][0].getRouteConfig,
handleRoute: defineRouterMock.mock.calls[0][0].handleRoute,
getApiConfig: defineRouterMock.mock.calls[0][0].getApiConfig,
handleApi: defineRouterMock.mock.calls[0][0].handleApi,
};
}

Expand Down Expand Up @@ -481,6 +539,73 @@ describe('createPages', () => {
expect(Object.keys(route.elements)).toEqual(['root', 'page:/test']);
});

it('creates a simple static api', async () => {
createPages(async ({ createApi }) => [
createApi({
path: '/test',
mode: 'static',
method: 'GET',
handler: async () => {
return new Response('Hello World');
},
}),
]);
const { getApiConfig, handleApi } = injectedFunctions();
expect(await getApiConfig()).toEqual([
{
path: [{ type: 'literal', name: 'test' }],
isStatic: true,
},
]);
const res = await handleApi('/test', {
method: 'GET',
headers: {},
body: null,
});
expect(res.headers).toEqual({
'content-type': 'text/plain;charset=UTF-8',
});
const respParsed = new Response(res.body);
const text = await respParsed.text();
expect(text).toEqual('Hello World');
expect(res.status).toEqual(200);
});

it('creates a simple dynamic api', async () => {
createPages(async ({ createApi }) => [
createApi({
path: '/test/[slug]',
mode: 'dynamic',
method: 'GET',
handler: async () => {
return new Response('Hello World');
},
}),
]);
const { getApiConfig, handleApi } = injectedFunctions();
expect(await getApiConfig()).toEqual([
{
path: [
{ type: 'literal', name: 'test' },
{ type: 'group', name: 'slug' },
],
isStatic: false,
},
]);
const res = await handleApi('/test/foo', {
method: 'GET',
headers: {},
body: null,
});
expect(res.headers).toEqual({
'content-type': 'text/plain;charset=UTF-8',
});
const respParsed = new Response(res.body);
const text = await respParsed.text();
expect(text).toEqual('Hello World');
expect(res.status).toEqual(200);
});

it('creates a simple static page with a layout', async () => {
const TestPage = () => null;
const TestLayout = ({ children }: PropsWithChildren) => children;
Expand Down Expand Up @@ -905,9 +1030,7 @@ describe('createPages', () => {
}),
]);
const { getRouteConfig } = injectedFunctions();
await expect(getRouteConfig).rejects.toThrowError(
'Duplicated dynamic path: /test',
);
await expect(getRouteConfig).rejects.toThrowError('Duplicated path: /test');
});

it('fails if duplicated static paths are registered', async () => {
Expand All @@ -924,9 +1047,7 @@ describe('createPages', () => {
}),
]);
const { getRouteConfig } = injectedFunctions();
await expect(getRouteConfig).rejects.toThrowError(
'Duplicated component for: test/page',
);
await expect(getRouteConfig).rejects.toThrowError('Duplicated path: /test');
});

it.fails(
Expand Down

0 comments on commit 058f9a1

Please sign in to comment.