Process redirects and parse resource locations for both server-side and client-side.master
@@ -126,7 +126,7 @@ In theory, any API route may have a corresponding action route. | |||||
- [X] Content negotiation (custom request data) | - [X] Content negotiation (custom request data) | ||||
- [ ] `<form method="dialog">` (on hold, see https://github.com/whatwg/html/issues/9625) | - [ ] `<form method="dialog">` (on hold, see https://github.com/whatwg/html/issues/9625) | ||||
- [ ] Tests | - [ ] Tests | ||||
- [ ] Form with redirects | |||||
- [X] Form with redirects | |||||
- [ ] Form with files | - [ ] Form with files | ||||
- [ ] Documentation | - [ ] Documentation | ||||
- [ ] Remix support | - [ ] Remix support | ||||
@@ -33,3 +33,4 @@ yarn-error.log* | |||||
# typescript | # typescript | ||||
*.tsbuildinfo | *.tsbuildinfo | ||||
next-env.d.ts | next-env.d.ts | ||||
.db/ |
@@ -214,6 +214,9 @@ const createNote = (params: NoteCollectionParams): NextApiHandler => async (req, | |||||
} | } | ||||
// how to genericize the location URL? | // how to genericize the location URL? | ||||
// TODO check if req.url only returns the path of this API | |||||
// req.url returns '/a/notes' when accessed from /a/notes, so we need to associate each action | |||||
// route to each API route | |||||
res.setHeader('Location', `${params.basePath}/${newId}`); | res.setHeader('Location', `${params.basePath}/${newId}`); | ||||
res.status(201).json({ | res.status(201).json({ | ||||
@@ -2,7 +2,7 @@ import {NextPage} from 'next'; | |||||
import * as Iceform from '@modal-sh/iceform-next'; | import * as Iceform from '@modal-sh/iceform-next'; | ||||
import {noteResource} from '@/handlers/note'; | import {noteResource} from '@/handlers/note'; | ||||
const ActionNotesIndexPage: NextPage = () => null; | |||||
const ActionNotesResourcePage: NextPage = () => null; | |||||
const getServerSideProps = Iceform.action.getServerSideProps({ | const getServerSideProps = Iceform.action.getServerSideProps({ | ||||
fn: noteResource, | fn: noteResource, | ||||
@@ -10,5 +10,5 @@ const getServerSideProps = Iceform.action.getServerSideProps({ | |||||
export { | export { | ||||
getServerSideProps, | getServerSideProps, | ||||
ActionNotesIndexPage as default, | |||||
ActionNotesResourcePage as default, | |||||
}; | }; |
@@ -2,15 +2,25 @@ import {NextPage} from 'next'; | |||||
import * as Iceform from '@modal-sh/iceform-next'; | import * as Iceform from '@modal-sh/iceform-next'; | ||||
import {noteCollection} from '@/handlers/note'; | import {noteCollection} from '@/handlers/note'; | ||||
const ActionNotesIndexPage: NextPage = () => null; | |||||
const ActionNotesCollectionPage: NextPage = () => null; | |||||
// this serves as an ID to associate the action URL to the API URL | |||||
const RESOURCE_BASE_PATH = '/api/notes'; | |||||
const getServerSideProps = Iceform.action.getServerSideProps({ | const getServerSideProps = Iceform.action.getServerSideProps({ | ||||
fn: noteCollection({ | fn: noteCollection({ | ||||
basePath: '/api/notes' | |||||
basePath: RESOURCE_BASE_PATH | |||||
}), | }), | ||||
mapLocationToRedirectDestination: (referer, url) => { | |||||
const resourceBaseUrl = `${RESOURCE_BASE_PATH}/`; | |||||
if (url.startsWith(resourceBaseUrl)) { | |||||
return `/notes/${url.slice(resourceBaseUrl.length)}`; | |||||
} | |||||
return referer; | |||||
}, | |||||
}); | }); | ||||
export { | export { | ||||
getServerSideProps, | getServerSideProps, | ||||
ActionNotesIndexPage as default, | |||||
ActionNotesCollectionPage as default, | |||||
}; | }; |
@@ -1,9 +1,12 @@ | |||||
import * as Iceform from '@modal-sh/iceform-next'; | import * as Iceform from '@modal-sh/iceform-next'; | ||||
import { noteCollection } from '@/handlers/note'; | import { noteCollection } from '@/handlers/note'; | ||||
// this serves as an ID to associate the action URL to the API URL | |||||
const RESOURCE_BASE_PATH = '/api/notes'; | |||||
const handler = Iceform.action.wrapApiHandler({ | const handler = Iceform.action.wrapApiHandler({ | ||||
fn: noteCollection({ | fn: noteCollection({ | ||||
basePath: '/api/notes' | |||||
basePath: RESOURCE_BASE_PATH, | |||||
}), | }), | ||||
}); | }); | ||||
@@ -5,7 +5,9 @@ const GreetPage: Iceform.NextPage = ({ | |||||
req, | req, | ||||
res, | res, | ||||
}) => { | }) => { | ||||
const {response, ...isoformProps} = Iceform.useResponse(res); | |||||
const {response, loading, ...isoformProps} = Iceform.useResponse({ | |||||
res | |||||
}); | |||||
const [responseData, setResponseData] = React.useState<unknown>(); | const [responseData, setResponseData] = React.useState<unknown>(); | ||||
React.useEffect(() => { | React.useEffect(() => { | ||||
@@ -1 +1,57 @@ | |||||
// TODO view note page | |||||
import * as Iceform from '@modal-sh/iceform-next'; | |||||
import * as React from 'react'; | |||||
const NotesItemPage: Iceform.NextPage = ({ | |||||
req, | |||||
res, | |||||
}) => { | |||||
const body = (res.body ?? {}) as Record<string, unknown>; | |||||
const {response, loading, ...isoformProps} = Iceform.useResponse({ | |||||
res | |||||
}); | |||||
const [responseData, setResponseData] = React.useState<unknown>(); | |||||
React.useEffect(() => { | |||||
// response.bodyUsed might be undefined, so we use a strict comparison | |||||
if (response?.bodyUsed === false) { | |||||
response?.json().then((responseData) => { | |||||
setResponseData(responseData); | |||||
}); | |||||
} | |||||
}, [response]); | |||||
return ( | |||||
<Iceform.Form | |||||
{...isoformProps} | |||||
method="post" | |||||
action={`/a/notes/${req.query.noteId}`} | |||||
clientAction={`/api/notes/${req.query.noteId}`} | |||||
> | |||||
<div> | |||||
<label> | |||||
<span>Title</span> | |||||
<input type="text" name="title" defaultValue={body.title as string} /> | |||||
</label> | |||||
</div> | |||||
<div> | |||||
<label> | |||||
<span>Image</span> | |||||
<input type="file" name="image" /> | |||||
</label> | |||||
</div> | |||||
<div> | |||||
<label> | |||||
<span>Content</span> | |||||
<textarea name="content" defaultValue={body.content as string} /> | |||||
</label> | |||||
</div> | |||||
<div> | |||||
<button type="submit">Submit</button> | |||||
</div> | |||||
</Iceform.Form> | |||||
) | |||||
}; | |||||
export const getServerSideProps = Iceform.destination.getServerSideProps(); | |||||
export default NotesItemPage; |
@@ -1 +1,36 @@ | |||||
// TODO view all notes/add notes page | |||||
import { NextPage } from 'next'; | |||||
import * as Iceform from '@modal-sh/iceform-next'; | |||||
const NotesPage: NextPage = () => { | |||||
return ( | |||||
<Iceform.Form | |||||
method="post" | |||||
action="/a/notes" | |||||
clientAction="/api/notes" | |||||
> | |||||
<div> | |||||
<label> | |||||
<span>Title</span> | |||||
<input type="text" name="title" /> | |||||
</label> | |||||
</div> | |||||
<div> | |||||
<label> | |||||
<span>Image</span> | |||||
<input type="file" name="image" /> | |||||
</label> | |||||
</div> | |||||
<div> | |||||
<label> | |||||
<span>Content</span> | |||||
<textarea name="content" /> | |||||
</label> | |||||
</div> | |||||
<div> | |||||
<button type="submit">Submit</button> | |||||
</div> | |||||
</Iceform.Form> | |||||
) | |||||
}; | |||||
export default NotesPage; |
@@ -10,22 +10,27 @@ export const useResponse = (params: UseResponseParams) => { | |||||
const [response, setResponse] = React.useState<Response | undefined>( | const [response, setResponse] = React.useState<Response | undefined>( | ||||
res.body ? new Response(res.body as unknown as BodyInit) : undefined, | res.body ? new Response(res.body as unknown as BodyInit) : undefined, | ||||
); | ); | ||||
const [loading, setLoading] = React.useState(false); | |||||
const invalidate = React.useCallback(() => { | const invalidate = React.useCallback(() => { | ||||
setResponse(undefined); | setResponse(undefined); | ||||
setLoading(true); | |||||
}, []); | }, []); | ||||
const refresh = React.useCallback((newResponse: Response) => { | const refresh = React.useCallback((newResponse: Response) => { | ||||
setResponse(newResponse); | setResponse(newResponse); | ||||
setLoading(false); | |||||
}, []); | }, []); | ||||
return React.useMemo(() => ({ | return React.useMemo(() => ({ | ||||
response, | response, | ||||
refresh, | refresh, | ||||
invalidate, | invalidate, | ||||
loading, | |||||
}), [ | }), [ | ||||
response, | response, | ||||
refresh, | refresh, | ||||
invalidate, | invalidate, | ||||
loading, | |||||
]); | ]); | ||||
}; | }; |
@@ -0,0 +1,8 @@ | |||||
export const METHODS_WITH_BODY = ['POST', 'PUT', 'PATCH', 'DELETE'] as const; | |||||
export const PREVENT_REDIRECT_FORM_KEY = '__iceform_prevent_redirect' as const; | |||||
export const DEFAULT_METHOD = 'GET' as const; | |||||
export const DEFAULT_ENCODING = 'utf-8' as const; | |||||
export const ACTION_STATUS_CODE = 307 as const; // temporary redirect | |||||
export const DEFAULT_RESPONSE_STATUS_CODE = 200 as const; // ok | |||||
export const CONTENT_TYPE_HEADER_KEY = 'content-type' as const; | |||||
export const LOCATION_HEADER_KEY = 'location' as const; |
@@ -2,47 +2,60 @@ import { | |||||
GetServerSideProps, | GetServerSideProps, | ||||
NextApiHandler, | NextApiHandler, | ||||
NextApiRequest as DefaultNextApiRequest, | NextApiRequest as DefaultNextApiRequest, | ||||
NextApiResponse as DefaultNextApiResponse, | |||||
PageConfig, | PageConfig, | ||||
} from 'next'; | } from 'next'; | ||||
import { deserialize, serialize } from 'seroval'; | |||||
import { deserialize } from 'seroval'; | |||||
import { | import { | ||||
NextApiResponse, | NextApiResponse, | ||||
NextApiRequest, | NextApiRequest, | ||||
} from './common/types'; | |||||
} from '../common/types'; | |||||
import { | import { | ||||
ENCTYPE_APPLICATION_JSON, | ENCTYPE_APPLICATION_JSON, | ||||
ENCTYPE_APPLICATION_OCTET_STREAM, | ENCTYPE_APPLICATION_OCTET_STREAM, | ||||
} from './common/enctypes'; | |||||
import { getBody } from './utils/request'; | |||||
} from '../common/enctypes'; | |||||
import { getBody } from '../utils/request'; | |||||
import { | import { | ||||
CookieManager, | CookieManager, | ||||
BODY_COOKIE_KEY, | BODY_COOKIE_KEY, | ||||
STATUS_CODE_COOKIE_KEY, | STATUS_CODE_COOKIE_KEY, | ||||
STATUS_MESSAGE_COOKIE_KEY, | STATUS_MESSAGE_COOKIE_KEY, | ||||
CONTENT_TYPE_COOKIE_KEY, | CONTENT_TYPE_COOKIE_KEY, | ||||
} from './utils/cookies'; | |||||
import { deserializeBody, EncTypeDeserializerMap } from './utils/serialization'; | |||||
} from '../utils/cookies'; | |||||
import { deserializeBody, EncTypeDeserializerMap } from '../utils/serialization'; | |||||
import { IceformNextServerResponse } from './response'; | |||||
import { | |||||
DEFAULT_METHOD, | |||||
DEFAULT_ENCODING, | |||||
DEFAULT_RESPONSE_STATUS_CODE, | |||||
ACTION_STATUS_CODE, | |||||
METHODS_WITH_BODY, | |||||
PREVENT_REDIRECT_FORM_KEY, | |||||
} from './constants'; | |||||
export namespace destination { | export namespace destination { | ||||
export const getServerSideProps = ( | export const getServerSideProps = ( | ||||
gspFn?: GetServerSideProps, | gspFn?: GetServerSideProps, | ||||
): GetServerSideProps => async (ctx) => { | ): GetServerSideProps => async (ctx) => { | ||||
const req: NextApiRequest = { | const req: NextApiRequest = { | ||||
query: ctx.query, | |||||
query: { | |||||
...ctx.query, | |||||
...(ctx.params ?? {}), | |||||
}, | |||||
body: null, | body: null, | ||||
}; | }; | ||||
const { method = 'GET' } = ctx.req; | |||||
const { method = DEFAULT_METHOD } = ctx.req; | |||||
if (!['GET', 'HEAD'].includes(method.toUpperCase())) { | |||||
if (METHODS_WITH_BODY.includes(method.toUpperCase() as typeof METHODS_WITH_BODY[number])) { | |||||
const body = await getBody(ctx.req); | const body = await getBody(ctx.req); | ||||
req.body = body.toString('utf-8'); | |||||
req.body = body.toString(DEFAULT_ENCODING); | |||||
} | } | ||||
const cookieManager = new CookieManager(ctx); | const cookieManager = new CookieManager(ctx); | ||||
// TODO how to properly remove cookies without leftovers? | // TODO how to properly remove cookies without leftovers? | ||||
if (cookieManager.hasCookie(STATUS_CODE_COOKIE_KEY)) { | if (cookieManager.hasCookie(STATUS_CODE_COOKIE_KEY)) { | ||||
ctx.res.statusCode = Number(cookieManager.getCookie(STATUS_CODE_COOKIE_KEY) || '200'); | |||||
ctx.res.statusCode = Number( | |||||
cookieManager.getCookie(STATUS_CODE_COOKIE_KEY) || DEFAULT_RESPONSE_STATUS_CODE, | |||||
); | |||||
cookieManager.unsetCookie(STATUS_CODE_COOKIE_KEY); | cookieManager.unsetCookie(STATUS_CODE_COOKIE_KEY); | ||||
} | } | ||||
@@ -103,6 +116,11 @@ export namespace action { | |||||
export interface ActionWrapperOptions { | export interface ActionWrapperOptions { | ||||
fn: NextApiHandler, | fn: NextApiHandler, | ||||
deserializers?: EncTypeDeserializerMap, | deserializers?: EncTypeDeserializerMap, | ||||
/** | |||||
* Maps the Location header from the handler response to an accessible URL. | |||||
* @param url | |||||
*/ | |||||
mapLocationToRedirectDestination?: (referer: string, url: string) => string, | |||||
} | } | ||||
export const wrapApiHandler = ( | export const wrapApiHandler = ( | ||||
@@ -120,7 +138,7 @@ export namespace action { | |||||
export const getServerSideProps = ( | export const getServerSideProps = ( | ||||
options: ActionWrapperOptions, | options: ActionWrapperOptions, | ||||
): GetServerSideProps => async (ctx) => { | ): GetServerSideProps => async (ctx) => { | ||||
const { referer } = ctx.req.headers; | |||||
const { referer = '/' } = ctx.req.headers; | |||||
const mockReq = { | const mockReq = { | ||||
...ctx.req, | ...ctx.req, | ||||
@@ -130,56 +148,39 @@ export namespace action { | |||||
}), | }), | ||||
} as DefaultNextApiRequest; | } as DefaultNextApiRequest; | ||||
let data: unknown = null; | |||||
let contentType: string | undefined; | |||||
const mockRes = { | |||||
// todo handle other nextapiresponse methods (e.g. setting headers, writeHead, etc.) | |||||
statusMessage: '', | |||||
statusCode: 200, | |||||
status(code: number) { | |||||
// should we mask error status code to Bad Gateway? | |||||
this.statusCode = code; | |||||
return mockRes; | |||||
}, | |||||
send: (raw?: unknown) => { | |||||
// xtodo: how to transfer binary response in a more compact way? | |||||
// > we let seroval handle this for now | |||||
if (typeof raw === 'undefined' || raw === null) { | |||||
return; | |||||
} | |||||
if (raw instanceof Buffer) { | |||||
contentType = ENCTYPE_APPLICATION_OCTET_STREAM; | |||||
} | |||||
data = serialize(raw); | |||||
}, | |||||
json: (raw: unknown) => { | |||||
contentType = ENCTYPE_APPLICATION_JSON; | |||||
data = serialize(raw); | |||||
}, | |||||
} as DefaultNextApiResponse; | |||||
const mockRes = new IceformNextServerResponse(ctx.req); | |||||
await options.fn(mockReq, mockRes); | await options.fn(mockReq, mockRes); | ||||
const cookieManager = new CookieManager(ctx); | const cookieManager = new CookieManager(ctx); | ||||
cookieManager.setCookie(STATUS_CODE_COOKIE_KEY, mockRes.statusCode.toString()); | cookieManager.setCookie(STATUS_CODE_COOKIE_KEY, mockRes.statusCode.toString()); | ||||
cookieManager.setCookie(STATUS_MESSAGE_COOKIE_KEY, mockRes.statusMessage); | cookieManager.setCookie(STATUS_MESSAGE_COOKIE_KEY, mockRes.statusMessage); | ||||
if (data) { | |||||
cookieManager.setCookie(BODY_COOKIE_KEY, data as string); | |||||
if (contentType) { | |||||
cookieManager.setCookie(CONTENT_TYPE_COOKIE_KEY, contentType); | |||||
if (mockRes.data) { | |||||
cookieManager.setCookie(BODY_COOKIE_KEY, mockRes.data as string); | |||||
if (mockRes.contentType) { | |||||
cookieManager.setCookie(CONTENT_TYPE_COOKIE_KEY, mockRes.contentType); | |||||
} | } | ||||
} | } | ||||
const preventRedirect = ( | |||||
typeof mockReq.body === 'object' | |||||
&& mockReq.body !== null | |||||
&& PREVENT_REDIRECT_FORM_KEY in mockReq.body | |||||
); | |||||
const redirectDestination = ( | |||||
mockRes.location | |||||
&& typeof options.mapLocationToRedirectDestination === 'function' | |||||
&& !preventRedirect | |||||
) | |||||
? options.mapLocationToRedirectDestination(referer, mockRes.location) | |||||
: referer; | |||||
return { | return { | ||||
redirect: { | redirect: { | ||||
destination: referer, | |||||
statusCode: 307, | |||||
destination: redirectDestination, | |||||
statusCode: ACTION_STATUS_CODE, | |||||
}, | }, | ||||
props: { | props: { | ||||
query: ctx.query, | query: ctx.query, | ||||
body: data, | |||||
body: mockRes.data, | |||||
}, | }, | ||||
}; | }; | ||||
}; | }; |
@@ -0,0 +1,93 @@ | |||||
import { ServerResponse } from 'http'; | |||||
import { NextApiResponse as DefaultNextApiResponse } from 'next/dist/shared/lib/utils'; | |||||
import { serialize } from 'seroval'; | |||||
import { ENCTYPE_APPLICATION_JSON, ENCTYPE_APPLICATION_OCTET_STREAM } from '../common/enctypes'; | |||||
import { ACTION_STATUS_CODE, CONTENT_TYPE_HEADER_KEY, LOCATION_HEADER_KEY } from './constants'; | |||||
class DummyServerResponse {} | |||||
const EffectiveServerResponse = ServerResponse ?? DummyServerResponse; | |||||
export class IceformNextServerResponse | |||||
extends EffectiveServerResponse | |||||
implements DefaultNextApiResponse { | |||||
data?: unknown; | |||||
contentType?: string; | |||||
location?: string; | |||||
private readonly revalidateResponse = Promise.resolve(undefined); | |||||
setHeader(name: string, value: number | string | readonly string[]): this { | |||||
super.setHeader(name, value); | |||||
if (name.toLowerCase() === CONTENT_TYPE_HEADER_KEY) { | |||||
this.contentType = value.toString(); | |||||
} | |||||
if (name.toLowerCase() === LOCATION_HEADER_KEY) { | |||||
this.location = value.toString(); | |||||
} | |||||
return this; | |||||
} | |||||
clearPreviewData(): DefaultNextApiResponse { | |||||
// unused | |||||
return this as unknown as DefaultNextApiResponse; | |||||
} | |||||
json(body: unknown): void { | |||||
this.contentType = ENCTYPE_APPLICATION_JSON; | |||||
this.data = serialize(body); | |||||
} | |||||
revalidate(): Promise<void> { | |||||
// unused | |||||
return this.revalidateResponse; | |||||
} | |||||
send(body: unknown): void { | |||||
// xtodo: how to transfer binary response in a more compact way? | |||||
// > we let seroval handle this for now | |||||
if (typeof body === 'undefined' || body === null) { | |||||
return; | |||||
} | |||||
if (body instanceof Buffer) { | |||||
this.contentType = ENCTYPE_APPLICATION_OCTET_STREAM; | |||||
} | |||||
this.data = serialize(body); | |||||
} | |||||
setDraftMode(): DefaultNextApiResponse { | |||||
// unused | |||||
return this as unknown as DefaultNextApiResponse; | |||||
} | |||||
setPreviewData(): DefaultNextApiResponse { | |||||
// unused | |||||
return this as unknown as DefaultNextApiResponse; | |||||
} | |||||
status(statusCode: number): DefaultNextApiResponse { | |||||
this.statusCode = statusCode; | |||||
return this as unknown as DefaultNextApiResponse; | |||||
} | |||||
strictContentLength = true; | |||||
redirect(...args: [string] | [number, string]): DefaultNextApiResponse { | |||||
const [arg1, arg2] = args; | |||||
if (typeof arg1 === 'number' && typeof arg2 === 'string') { | |||||
this.statusCode = arg1; | |||||
this.setHeader('Location', arg2); | |||||
} else if (typeof arg1 === 'string') { | |||||
this.statusCode = ACTION_STATUS_CODE; | |||||
this.setHeader('Location', arg1); | |||||
} | |||||
return this as unknown as DefaultNextApiResponse; | |||||
} | |||||
} |
@@ -38,9 +38,10 @@ export const parseMultipartFormData = async ( | |||||
}); | }); | ||||
file.on('close', () => { | file.on('close', () => { | ||||
body[name] = new File([fileData.buffer], filename, { | |||||
type: mimetype, | |||||
}); | |||||
const newFile = fileData.buffer as unknown as Record<string, unknown>; | |||||
newFile.name = filename; | |||||
newFile.type = mimetype; | |||||
body[name] = newFile; | |||||
}); | }); | ||||
}); | }); | ||||