Allow ways to send methods other than get/post on server-side.master
@@ -6,6 +6,18 @@ const ActionNotesResourcePage: NextPage = () => null; | |||
const getServerSideProps = Iceform.action.getServerSideProps({ | |||
fn: noteResource, | |||
onAction: async (context) => { | |||
if (context.req.method?.toLowerCase() === 'delete') { | |||
return { | |||
redirect: { | |||
destination: '/notes', | |||
permanent: false, | |||
}, | |||
}; | |||
} | |||
// use default behavior | |||
}, | |||
}); | |||
export { | |||
@@ -1,11 +1,21 @@ | |||
import * as Iceform from '@modal-sh/iceform-next'; | |||
import * as React from 'react'; | |||
const NotesItemPage: Iceform.NextPage = ({ | |||
export interface NotesItemPageProps { | |||
note: { | |||
id: string; | |||
title: string; | |||
content: string; | |||
image: string; | |||
} | |||
} | |||
const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({ | |||
req, | |||
res, | |||
note, | |||
}) => { | |||
const body = (res.body ?? {}) as Record<string, unknown>; | |||
const body = (res.body ?? note ?? {}) as Record<string, unknown>; | |||
const {response, loading, ...isoformProps} = Iceform.useResponse({ | |||
res | |||
}); | |||
@@ -26,22 +36,23 @@ const NotesItemPage: Iceform.NextPage = ({ | |||
method="post" | |||
action={`/a/notes/${req.query.noteId}`} | |||
clientAction={`/api/notes/${req.query.noteId}`} | |||
clientMethod="put" | |||
> | |||
<div> | |||
<label> | |||
<span>Title</span> | |||
<span className="after:block">Title</span> | |||
<input type="text" name="title" defaultValue={body.title as string} /> | |||
</label> | |||
</div> | |||
<div> | |||
<label> | |||
<span>Image</span> | |||
<span className="after:block">Image</span> | |||
<input type="file" name="image" /> | |||
</label> | |||
</div> | |||
<div> | |||
<label> | |||
<span>Content</span> | |||
<span className="after:block">Content</span> | |||
<textarea name="content" defaultValue={body.content as string} /> | |||
</label> | |||
</div> | |||
@@ -49,9 +60,40 @@ const NotesItemPage: Iceform.NextPage = ({ | |||
<button type="submit">Submit</button> | |||
</div> | |||
</Iceform.Form> | |||
) | |||
); | |||
}; | |||
export const getServerSideProps = Iceform.destination.getServerSideProps(); | |||
export const getServerSideProps = Iceform.destination.getServerSideProps({ | |||
fn: async (actionReq, actionRes, ctx) => { | |||
const {noteId} = ctx.query; | |||
let origin: string; | |||
if (ctx.req.headers.referer) { | |||
const refererUrl = new URL(ctx.req.headers.referer as string); | |||
origin = refererUrl.origin; | |||
} else { | |||
// TODO how to get the scheme? | |||
const scheme = 'http'; | |||
origin = `${scheme}://${ctx.req.headers.host}`; | |||
} | |||
const url = new URL(`/api/notes/${noteId}`, origin); | |||
const noteResponse = await fetch(url.toString(), { | |||
headers: { | |||
'Accept': 'application/json', | |||
} | |||
}); | |||
if (noteResponse.ok) { | |||
const note = await noteResponse.json(); | |||
return { | |||
props: { | |||
note, | |||
}, | |||
}; | |||
} | |||
return { | |||
notFound: true, | |||
}; | |||
}, | |||
}); | |||
export default NotesItemPage; |
@@ -10,19 +10,19 @@ const NotesPage: NextPage = () => { | |||
> | |||
<div> | |||
<label> | |||
<span>Title</span> | |||
<span className="after:block">Title</span> | |||
<input type="text" name="title" /> | |||
</label> | |||
</div> | |||
<div> | |||
<label> | |||
<span>Image</span> | |||
<span className="after:block">Image</span> | |||
<input type="file" name="image" /> | |||
</label> | |||
</div> | |||
<div> | |||
<label> | |||
<span>Content</span> | |||
<span className="after:block">Content</span> | |||
<textarea name="content" /> | |||
</label> | |||
</div> | |||
@@ -25,3 +25,7 @@ body { | |||
) | |||
rgb(var(--background-start-rgb)); | |||
} | |||
input, select, textarea { | |||
background-color: rgb(var(--background-start-rgb)); | |||
} |
@@ -105,3 +105,4 @@ dist | |||
.tern-port | |||
.npmrc | |||
types/ |
@@ -15,6 +15,7 @@ import { | |||
FormDerivedElementComponent, | |||
} from '../common'; | |||
import { useFormFetch } from '../hooks/useFormFetch'; | |||
import { METHOD_FORM_KEY } from '../../common/constants'; | |||
export interface FormProps extends Omit<React.HTMLProps<FormDerivedElement>, 'action' | 'method'> { | |||
action?: string; | |||
@@ -59,9 +60,16 @@ export const Form = React.forwardRef<FormDerivedElement, FormProps>(({ | |||
responseEncType, | |||
serializerOptions, | |||
}); | |||
const serverMethodRaw = method.toLowerCase(); | |||
const serverMethod = serverMethodRaw === 'get' ? 'get' : 'post'; | |||
const [serverMethodOverride, setServerMethodOverride] = React.useState( | |||
serverMethod.toLowerCase() !== clientMethod.toLowerCase(), | |||
); | |||
React.useEffect(() => { | |||
// hide server override in client | |||
setServerMethodOverride(false); | |||
}, []); | |||
return ( | |||
<FormDerivedElementComponent | |||
@@ -72,6 +80,9 @@ export const Form = React.forwardRef<FormDerivedElement, FormProps>(({ | |||
method={serverMethod} | |||
encType={ENCTYPE_MULTIPART_FORM_DATA} | |||
> | |||
{serverMethodOverride && ( | |||
<input type="hidden" name={METHOD_FORM_KEY} value={clientMethod} /> | |||
)} | |||
{children} | |||
</FormDerivedElementComponent> | |||
); | |||
@@ -0,0 +1,2 @@ | |||
export const PREVENT_REDIRECT_FORM_KEY = '__iceform_prevent_redirect' as const; | |||
export const METHOD_FORM_KEY = '__iceform_method' as const; |
@@ -0,0 +1,141 @@ | |||
import { | |||
GetServerSideProps, | |||
NextApiHandler, | |||
PageConfig, | |||
NextApiRequest as DefaultNextApiRequest, PreviewData, GetServerSidePropsContext, | |||
} from 'next'; | |||
import { ParsedUrlQuery } from 'querystring'; | |||
import { deserializeBody, EncTypeDeserializerMap } from '../utils/serialization'; | |||
import { IceformNextServerResponse } from './response'; | |||
import { | |||
BODY_COOKIE_KEY, CONTENT_TYPE_COOKIE_KEY, | |||
CookieManager, | |||
STATUS_CODE_COOKIE_KEY, | |||
STATUS_MESSAGE_COOKIE_KEY, | |||
} from '../utils/cookies'; | |||
import { | |||
METHOD_FORM_KEY, | |||
PREVENT_REDIRECT_FORM_KEY, | |||
} from '../common/constants'; | |||
import { | |||
ACTION_STATUS_CODE, | |||
DEFAULT_METHOD, | |||
} from './constants'; | |||
export const getApiConfig = (customConfig = {} as PageConfig) => ({ | |||
api: { | |||
...(customConfig.api ?? {}), | |||
bodyParser: false, | |||
}, | |||
}); | |||
export type OnActionFunction< | |||
Props extends Record<string, unknown> = Record<string, unknown>, | |||
Params extends ParsedUrlQuery = ParsedUrlQuery, | |||
Preview extends PreviewData = PreviewData, | |||
> = ( | |||
context: GetServerSidePropsContext<Params, Preview>, | |||
) => Promise<Awaited<ReturnType<GetServerSideProps<Props, Params, Preview>>> | undefined>; | |||
export interface ActionWrapperOptions { | |||
fn: NextApiHandler, | |||
onAction?: OnActionFunction, | |||
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 = ( | |||
options: ActionWrapperOptions, | |||
): NextApiHandler => async (req, res) => { | |||
const body = await deserializeBody({ | |||
req, | |||
deserializers: options.deserializers, | |||
}); | |||
const reqMut = req as unknown as Record<string, unknown>; | |||
reqMut.body = body; | |||
return options.fn(reqMut as unknown as DefaultNextApiRequest, res); | |||
}; | |||
export const getServerSideProps = ( | |||
options: ActionWrapperOptions, | |||
): GetServerSideProps => async (ctx) => { | |||
const { referer = '/' } = ctx.req.headers; | |||
const deserialized = await deserializeBody({ | |||
req: ctx.req, | |||
deserializers: options.deserializers, | |||
}); | |||
const defaultMethod = ctx.req.method ?? DEFAULT_METHOD; | |||
let effectiveMethod = defaultMethod; | |||
let mockReqBody: unknown = deserialized; | |||
if (typeof deserialized === 'object' && deserialized !== null) { | |||
const { | |||
[METHOD_FORM_KEY]: theMethod, | |||
...theMockReqBody | |||
} = deserialized as { | |||
[METHOD_FORM_KEY]?: string, | |||
[key: string]: unknown, | |||
}; | |||
effectiveMethod = theMethod ?? defaultMethod; | |||
mockReqBody = theMockReqBody; | |||
} | |||
const mockReq = { | |||
...ctx.req, | |||
body: mockReqBody, | |||
query: { | |||
...ctx.query, | |||
...(ctx.params ?? {}), | |||
}, | |||
// ?: how to prevent malicious method spoofing? | |||
method: effectiveMethod, | |||
} as DefaultNextApiRequest; | |||
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 (mockRes.data) { | |||
cookieManager.setCookie(BODY_COOKIE_KEY, mockRes.data as string); | |||
if (mockRes.contentType) { | |||
cookieManager.setCookie(CONTENT_TYPE_COOKIE_KEY, mockRes.contentType); | |||
} | |||
} | |||
if (typeof options.onAction === 'function') { | |||
const onActionResult = await options.onAction(ctx); | |||
if (onActionResult) { | |||
return onActionResult; | |||
} | |||
} | |||
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: redirectDestination, | |||
statusCode: ACTION_STATUS_CODE, | |||
}, | |||
props: { | |||
query: ctx.query, | |||
body: mockRes.data, | |||
}, | |||
}; | |||
}; |
@@ -1,5 +1,4 @@ | |||
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 | |||
@@ -0,0 +1,103 @@ | |||
import { ParsedUrlQuery } from 'querystring'; | |||
import { GetServerSideProps, GetServerSidePropsContext, PreviewData } from 'next'; | |||
import { deserialize } from 'seroval'; | |||
import { NextApiRequest, NextApiResponse } from '../common/types'; | |||
import { | |||
DEFAULT_ENCODING, | |||
DEFAULT_METHOD, | |||
DEFAULT_RESPONSE_STATUS_CODE, | |||
METHODS_WITH_BODY, | |||
} from './constants'; | |||
import { getBody } from '../utils/request'; | |||
import { | |||
BODY_COOKIE_KEY, CONTENT_TYPE_COOKIE_KEY, | |||
CookieManager, | |||
STATUS_CODE_COOKIE_KEY, | |||
STATUS_MESSAGE_COOKIE_KEY, | |||
} from '../utils/cookies'; | |||
import { ENCTYPE_APPLICATION_JSON, ENCTYPE_APPLICATION_OCTET_STREAM } from '../common/enctypes'; | |||
export type DestinationGetServerSideProps< | |||
Props extends Record<string, unknown> = Record<string, unknown>, | |||
Params extends ParsedUrlQuery = ParsedUrlQuery, | |||
Preview extends PreviewData = PreviewData, | |||
> = ( | |||
actionReq: NextApiRequest, | |||
actionRes: NextApiResponse, | |||
context: GetServerSidePropsContext<Params, Preview>, | |||
) => ReturnType<GetServerSideProps<Props, Params, Preview>>; | |||
export interface DestinationWrapperOptions { | |||
fn?: DestinationGetServerSideProps; | |||
} | |||
export const getServerSideProps = ( | |||
options = {} as DestinationWrapperOptions, | |||
): GetServerSideProps => async (ctx) => { | |||
const req: NextApiRequest = { | |||
query: { | |||
...ctx.query, | |||
...(ctx.params ?? {}), | |||
}, | |||
body: null, | |||
}; | |||
const { method = DEFAULT_METHOD } = ctx.req; | |||
if (METHODS_WITH_BODY.includes(method.toUpperCase() as typeof METHODS_WITH_BODY[number])) { | |||
const body = await getBody(ctx.req); | |||
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) || DEFAULT_RESPONSE_STATUS_CODE, | |||
); | |||
cookieManager.unsetCookie(STATUS_CODE_COOKIE_KEY); | |||
} | |||
if (cookieManager.hasCookie(STATUS_MESSAGE_COOKIE_KEY)) { | |||
ctx.res.statusMessage = cookieManager.getCookie(STATUS_MESSAGE_COOKIE_KEY) || ''; | |||
cookieManager.unsetCookie(STATUS_MESSAGE_COOKIE_KEY); | |||
} | |||
const res: NextApiResponse = {}; | |||
if (cookieManager.hasCookie(BODY_COOKIE_KEY)) { | |||
const resBody = cookieManager.getCookie(BODY_COOKIE_KEY); | |||
const contentType = cookieManager.getCookie(CONTENT_TYPE_COOKIE_KEY); | |||
if (contentType === ENCTYPE_APPLICATION_JSON) { | |||
res.body = deserialize(resBody); | |||
} else if (contentType === ENCTYPE_APPLICATION_OCTET_STREAM) { | |||
res.body = deserialize(resBody); | |||
} else { | |||
const c = console; | |||
c.warn('Could not parse response body, returning nothing'); | |||
res.body = null; | |||
} | |||
cookieManager.unsetCookie(BODY_COOKIE_KEY); | |||
cookieManager.unsetCookie(CONTENT_TYPE_COOKIE_KEY); | |||
} | |||
const gspResult = ( | |||
typeof options?.fn === 'function' | |||
? await options.fn(req, res, ctx) | |||
: { | |||
props: {}, | |||
} | |||
); | |||
if ('props' in gspResult) { | |||
return { | |||
...gspResult, | |||
props: { | |||
...gspResult.props, | |||
req, | |||
res, | |||
}, | |||
}; | |||
} | |||
// redirect/not found will be treated as default behavior | |||
return gspResult; | |||
}; |
@@ -1,187 +1,2 @@ | |||
import { | |||
GetServerSideProps, | |||
NextApiHandler, | |||
NextApiRequest as DefaultNextApiRequest, | |||
PageConfig, | |||
} from 'next'; | |||
import { deserialize } from 'seroval'; | |||
import { | |||
NextApiResponse, | |||
NextApiRequest, | |||
} from '../common/types'; | |||
import { | |||
ENCTYPE_APPLICATION_JSON, | |||
ENCTYPE_APPLICATION_OCTET_STREAM, | |||
} 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'; | |||
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, | |||
...(ctx.params ?? {}), | |||
}, | |||
body: null, | |||
}; | |||
const { method = DEFAULT_METHOD } = ctx.req; | |||
if (METHODS_WITH_BODY.includes(method.toUpperCase() as typeof METHODS_WITH_BODY[number])) { | |||
const body = await getBody(ctx.req); | |||
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) || DEFAULT_RESPONSE_STATUS_CODE, | |||
); | |||
cookieManager.unsetCookie(STATUS_CODE_COOKIE_KEY); | |||
} | |||
if (cookieManager.hasCookie(STATUS_MESSAGE_COOKIE_KEY)) { | |||
ctx.res.statusMessage = cookieManager.getCookie(STATUS_MESSAGE_COOKIE_KEY) || ''; | |||
cookieManager.unsetCookie(STATUS_MESSAGE_COOKIE_KEY); | |||
} | |||
const res: NextApiResponse = {}; | |||
if (cookieManager.hasCookie(BODY_COOKIE_KEY)) { | |||
const resBody = cookieManager.getCookie(BODY_COOKIE_KEY); | |||
const contentType = cookieManager.getCookie(CONTENT_TYPE_COOKIE_KEY); | |||
if (contentType === ENCTYPE_APPLICATION_JSON) { | |||
res.body = deserialize(resBody); | |||
} else if (contentType === ENCTYPE_APPLICATION_OCTET_STREAM) { | |||
res.body = deserialize(resBody); | |||
} else { | |||
const c = console; | |||
c.warn('Could not parse response body, returning nothing'); | |||
res.body = null; | |||
} | |||
cookieManager.unsetCookie(BODY_COOKIE_KEY); | |||
cookieManager.unsetCookie(CONTENT_TYPE_COOKIE_KEY); | |||
} | |||
let gspResult; | |||
if (gspFn) { | |||
gspResult = await gspFn(ctx); | |||
} else { | |||
gspResult = { | |||
props: {}, | |||
}; | |||
} | |||
if ('props' in gspResult) { | |||
return { | |||
...gspResult, | |||
props: { | |||
...gspResult.props, | |||
req, | |||
res, | |||
}, | |||
}; | |||
} | |||
// redirect/not found will be treated as default behavior | |||
return gspResult; | |||
}; | |||
} | |||
export namespace action { | |||
export const getApiConfig = (customConfig = {} as PageConfig) => ({ | |||
api: { | |||
...(customConfig.api ?? {}), | |||
bodyParser: false, | |||
}, | |||
}); | |||
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 = ( | |||
options: ActionWrapperOptions, | |||
): NextApiHandler => async (req, res) => { | |||
const body = await deserializeBody({ | |||
req, | |||
deserializers: options.deserializers, | |||
}); | |||
const reqMut = req as unknown as Record<string, unknown>; | |||
reqMut.body = body; | |||
return options.fn(reqMut as unknown as DefaultNextApiRequest, res); | |||
}; | |||
export const getServerSideProps = ( | |||
options: ActionWrapperOptions, | |||
): GetServerSideProps => async (ctx) => { | |||
const { referer = '/' } = ctx.req.headers; | |||
const mockReq = { | |||
...ctx.req, | |||
body: await deserializeBody({ | |||
req: ctx.req, | |||
deserializers: options.deserializers, | |||
}), | |||
} as DefaultNextApiRequest; | |||
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 (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: redirectDestination, | |||
statusCode: ACTION_STATUS_CODE, | |||
}, | |||
props: { | |||
query: ctx.query, | |||
body: mockRes.data, | |||
}, | |||
}; | |||
}; | |||
} | |||
export * as action from './action'; | |||
export * as destination from './destination'; |
@@ -64,7 +64,7 @@ export const DEFAULT_ENCTYPE_DESERIALIZERS: EncTypeDeserializerMap = { | |||
[ENCTYPE_APPLICATION_JSON]: (data: string) => JSON.parse(data) as Record<string, unknown>, | |||
}; | |||
export const deserializeBody = async (params: DeserializeBodyParams) => { | |||
export const deserializeBody = async (params: DeserializeBodyParams): Promise<unknown> => { | |||
const { req, deserializers = DEFAULT_ENCTYPE_DESERIALIZERS } = params; | |||
const contentType = req.headers['content-type'] ?? ENCTYPE_APPLICATION_OCTET_STREAM; | |||