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) | |||
- [ ] `<form method="dialog">` (on hold, see https://github.com/whatwg/html/issues/9625) | |||
- [ ] Tests | |||
- [ ] Form with redirects | |||
- [X] Form with redirects | |||
- [ ] Form with files | |||
- [ ] Documentation | |||
- [ ] Remix support | |||
@@ -33,3 +33,4 @@ yarn-error.log* | |||
# typescript | |||
*.tsbuildinfo | |||
next-env.d.ts | |||
.db/ |
@@ -214,6 +214,9 @@ const createNote = (params: NoteCollectionParams): NextApiHandler => async (req, | |||
} | |||
// 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.status(201).json({ | |||
@@ -2,7 +2,7 @@ import {NextPage} from 'next'; | |||
import * as Iceform from '@modal-sh/iceform-next'; | |||
import {noteResource} from '@/handlers/note'; | |||
const ActionNotesIndexPage: NextPage = () => null; | |||
const ActionNotesResourcePage: NextPage = () => null; | |||
const getServerSideProps = Iceform.action.getServerSideProps({ | |||
fn: noteResource, | |||
@@ -10,5 +10,5 @@ const getServerSideProps = Iceform.action.getServerSideProps({ | |||
export { | |||
getServerSideProps, | |||
ActionNotesIndexPage as default, | |||
ActionNotesResourcePage as default, | |||
}; |
@@ -2,15 +2,25 @@ import {NextPage} from 'next'; | |||
import * as Iceform from '@modal-sh/iceform-next'; | |||
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({ | |||
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 { | |||
getServerSideProps, | |||
ActionNotesIndexPage as default, | |||
ActionNotesCollectionPage as default, | |||
}; |
@@ -1,9 +1,12 @@ | |||
import * as Iceform from '@modal-sh/iceform-next'; | |||
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({ | |||
fn: noteCollection({ | |||
basePath: '/api/notes' | |||
basePath: RESOURCE_BASE_PATH, | |||
}), | |||
}); | |||
@@ -5,7 +5,9 @@ const GreetPage: Iceform.NextPage = ({ | |||
req, | |||
res, | |||
}) => { | |||
const {response, ...isoformProps} = Iceform.useResponse(res); | |||
const {response, loading, ...isoformProps} = Iceform.useResponse({ | |||
res | |||
}); | |||
const [responseData, setResponseData] = React.useState<unknown>(); | |||
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>( | |||
res.body ? new Response(res.body as unknown as BodyInit) : undefined, | |||
); | |||
const [loading, setLoading] = React.useState(false); | |||
const invalidate = React.useCallback(() => { | |||
setResponse(undefined); | |||
setLoading(true); | |||
}, []); | |||
const refresh = React.useCallback((newResponse: Response) => { | |||
setResponse(newResponse); | |||
setLoading(false); | |||
}, []); | |||
return React.useMemo(() => ({ | |||
response, | |||
refresh, | |||
invalidate, | |||
loading, | |||
}), [ | |||
response, | |||
refresh, | |||
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, | |||
NextApiHandler, | |||
NextApiRequest as DefaultNextApiRequest, | |||
NextApiResponse as DefaultNextApiResponse, | |||
PageConfig, | |||
} from 'next'; | |||
import { deserialize, serialize } from 'seroval'; | |||
import { deserialize } from 'seroval'; | |||
import { | |||
NextApiResponse, | |||
NextApiRequest, | |||
} from './common/types'; | |||
} from '../common/types'; | |||
import { | |||
ENCTYPE_APPLICATION_JSON, | |||
ENCTYPE_APPLICATION_OCTET_STREAM, | |||
} from './common/enctypes'; | |||
import { getBody } from './utils/request'; | |||
} from '../common/enctypes'; | |||
import { getBody } from '../utils/request'; | |||
import { | |||
CookieManager, | |||
BODY_COOKIE_KEY, | |||
STATUS_CODE_COOKIE_KEY, | |||
STATUS_MESSAGE_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 const getServerSideProps = ( | |||
gspFn?: GetServerSideProps, | |||
): GetServerSideProps => async (ctx) => { | |||
const req: NextApiRequest = { | |||
query: ctx.query, | |||
query: { | |||
...ctx.query, | |||
...(ctx.params ?? {}), | |||
}, | |||
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); | |||
req.body = body.toString('utf-8'); | |||
req.body = body.toString(DEFAULT_ENCODING); | |||
} | |||
const cookieManager = new CookieManager(ctx); | |||
// TODO how to properly remove cookies without leftovers? | |||
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); | |||
} | |||
@@ -103,6 +116,11 @@ export namespace action { | |||
export interface ActionWrapperOptions { | |||
fn: NextApiHandler, | |||
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 = ( | |||
@@ -120,7 +138,7 @@ export namespace action { | |||
export const getServerSideProps = ( | |||
options: ActionWrapperOptions, | |||
): GetServerSideProps => async (ctx) => { | |||
const { referer } = ctx.req.headers; | |||
const { referer = '/' } = ctx.req.headers; | |||
const mockReq = { | |||
...ctx.req, | |||
@@ -130,56 +148,39 @@ export namespace action { | |||
}), | |||
} 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); | |||
const cookieManager = new CookieManager(ctx); | |||
cookieManager.setCookie(STATUS_CODE_COOKIE_KEY, mockRes.statusCode.toString()); | |||
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 { | |||
redirect: { | |||
destination: referer, | |||
statusCode: 307, | |||
destination: redirectDestination, | |||
statusCode: ACTION_STATUS_CODE, | |||
}, | |||
props: { | |||
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', () => { | |||
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; | |||
}); | |||
}); | |||