Browse Source

Implement adjustments to handlers

Allow ways to send methods other than get/post on server-side.
master
TheoryOfNekomata 9 months ago
parent
commit
8230e6549b
12 changed files with 330 additions and 200 deletions
  1. +12
    -0
      packages/iceform-next-sandbox/src/pages/a/notes/[noteId].ts
  2. +49
    -7
      packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx
  3. +3
    -3
      packages/iceform-next-sandbox/src/pages/notes/index.tsx
  4. +4
    -0
      packages/iceform-next-sandbox/src/styles/globals.css
  5. +1
    -0
      packages/iceform-next/.gitignore
  6. +12
    -1
      packages/iceform-next/src/client/components/Form.tsx
  7. +2
    -0
      packages/iceform-next/src/common/constants.ts
  8. +141
    -0
      packages/iceform-next/src/server/action.ts
  9. +0
    -1
      packages/iceform-next/src/server/constants.ts
  10. +103
    -0
      packages/iceform-next/src/server/destination.ts
  11. +2
    -187
      packages/iceform-next/src/server/index.ts
  12. +1
    -1
      packages/iceform-next/src/utils/serialization.ts

+ 12
- 0
packages/iceform-next-sandbox/src/pages/a/notes/[noteId].ts View File

@@ -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 {


+ 49
- 7
packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx View File

@@ -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;

+ 3
- 3
packages/iceform-next-sandbox/src/pages/notes/index.tsx View File

@@ -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>


+ 4
- 0
packages/iceform-next-sandbox/src/styles/globals.css View File

@@ -25,3 +25,7 @@ body {
)
rgb(var(--background-start-rgb));
}

input, select, textarea {
background-color: rgb(var(--background-start-rgb));
}

+ 1
- 0
packages/iceform-next/.gitignore View File

@@ -105,3 +105,4 @@ dist
.tern-port

.npmrc
types/

+ 12
- 1
packages/iceform-next/src/client/components/Form.tsx View File

@@ -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>
);


+ 2
- 0
packages/iceform-next/src/common/constants.ts View File

@@ -0,0 +1,2 @@
export const PREVENT_REDIRECT_FORM_KEY = '__iceform_prevent_redirect' as const;
export const METHOD_FORM_KEY = '__iceform_method' as const;

+ 141
- 0
packages/iceform-next/src/server/action.ts View File

@@ -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,
},
};
};

+ 0
- 1
packages/iceform-next/src/server/constants.ts View File

@@ -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


+ 103
- 0
packages/iceform-next/src/server/destination.ts View File

@@ -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;
};

+ 2
- 187
packages/iceform-next/src/server/index.ts View File

@@ -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';

+ 1
- 1
packages/iceform-next/src/utils/serialization.ts View File

@@ -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;



Loading…
Cancel
Save