Browse Source

Fix method handling

Allow handler to check for original method.
master
TheoryOfNekomata 1 year ago
parent
commit
df012a43cc
15 changed files with 117 additions and 93 deletions
  1. +2
    -0
      README.md
  2. +2
    -2
      packages/iceform-next-sandbox/src/pages/a/notes/[noteId].ts
  3. +1
    -1
      packages/iceform-next-sandbox/src/pages/greet.tsx
  4. +59
    -31
      packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx
  5. +10
    -0
      packages/iceform-next-sandbox/src/pages/notes/index.tsx
  6. +1
    -2
      packages/iceform-next/package.json
  7. +3
    -3
      packages/iceform-next/src/client/common.ts
  8. +2
    -0
      packages/iceform-next/src/client/components/Form.tsx
  9. +4
    -3
      packages/iceform-next/src/client/hooks/useFormFetch.ts
  10. +1
    -0
      packages/iceform-next/src/common/constants.ts
  11. +14
    -7
      packages/iceform-next/src/server/action.ts
  12. +0
    -1
      packages/iceform-next/src/server/constants.ts
  13. +1
    -1
      packages/iceform-next/src/server/destination.ts
  14. +17
    -4
      packages/iceform-next/src/utils/serialization.ts
  15. +0
    -38
      pnpm-lock.yaml

+ 2
- 0
README.md View File

@@ -131,3 +131,5 @@ In theory, any API route may have a corresponding action route.
- [ ] Documentation
- [ ] Remix support
- [ ] `accept-charset=""` attribute support
- [X] Method override support
- Integration with Next router (iceform-next)

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

@@ -6,8 +6,8 @@ const ActionNotesResourcePage: NextPage = () => null;

const getServerSideProps = Iceform.action.getServerSideProps({
fn: noteResource,
onAction: async (context) => {
if (context.req.method?.toLowerCase() === 'delete') {
onAction: async (req, res) => {
if (req.method?.toLowerCase() === 'delete') {
return {
redirect: {
destination: '/notes',


+ 1
- 1
packages/iceform-next-sandbox/src/pages/greet.tsx View File

@@ -12,7 +12,7 @@ const GreetPage: Iceform.NextPage = ({
const [responseData, setResponseData] = React.useState<unknown>();
React.useEffect(() => {
// response.bodyUsed might be undefined, so we use a strict comparison
if (response?.bodyUsed === false) {
if (response?.bodyUsed === false && response.status !== 204) {
response?.json().then((responseData) => {
setResponseData(responseData);
});


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

@@ -1,5 +1,6 @@
import * as Iceform from '@modal-sh/iceform-next';
import * as React from 'react';
import { useRouter } from 'next/router';

export interface NotesItemPageProps {
note: {
@@ -15,15 +16,21 @@ const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({
res,
note,
}) => {
const router = useRouter();
const body = (res.body ?? note ?? {}) as Record<string, unknown>;
const {response, loading, ...isoformProps} = Iceform.useResponse({
const {
response,
loading,
refresh: defaultRefresh,
...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) {
if (response?.bodyUsed === false && response.status !== 204) {
response?.json().then((responseData) => {
setResponseData(responseData);
});
@@ -31,38 +38,59 @@ const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({
}, [response]);

return (
<Iceform.Form
{...isoformProps}
method="post"
action={`/a/notes/${req.query.noteId}`}
clientAction={`/api/notes/${req.query.noteId}`}
clientMethod="put"
>
<div>
<label>
<span className="after:block">Title</span>
<input type="text" name="title" defaultValue={body.title as string} />
</label>
</div>
<div>
<label>
<span className="after:block">Image</span>
<input type="file" name="image" />
</label>
</div>
<div>
<label>
<span className="after:block">Content</span>
<textarea name="content" defaultValue={body.content as string} />
</label>
</div>
<div>
<button type="submit">Submit</button>
</div>
</Iceform.Form>
<div>
<Iceform.Form
{...isoformProps}
className="contents"
method="post"
action={`/a/notes/${req.query.noteId}`}
clientAction={`/api/notes/${req.query.noteId}`}
clientMethod="put"
refresh={defaultRefresh}
>
<div>
<label>
<span className="after:block">Title</span>
<input type="text" name="title" defaultValue={body.title as string} />
</label>
</div>
<div>
<label>
<span className="after:block">Image</span>
<input type="file" name="image" />
</label>
</div>
<div>
<label>
<span className="after:block">Content</span>
<textarea name="content" defaultValue={body.content as string} />
</label>
</div>
<div>
<button type="submit">Submit</button>
</div>
</Iceform.Form>
<Iceform.Form
{...isoformProps}
method="post"
action={`/a/notes/${req.query.noteId}`}
clientAction={`/api/notes/${req.query.noteId}`}
clientMethod="delete"
className="contents"
refresh={async (response) => {
defaultRefresh(response);
await router.push('/notes');
}}
>
<div>
<button type="submit">Delete</button>
</div>
</Iceform.Form>
</div>
);
};

// TODO type safety
export const getServerSideProps = Iceform.destination.getServerSideProps({
fn: async (actionReq, actionRes, ctx) => {
const {noteId} = ctx.query;


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

@@ -1,12 +1,22 @@
import { NextPage } from 'next';
import * as Iceform from '@modal-sh/iceform-next';
import { useRouter } from 'next/router';

const NotesPage: NextPage = () => {
const router = useRouter();

return (
<Iceform.Form
method="post"
action="/a/notes"
clientAction="/api/notes"
refresh={async (response) => {
if (response.status !== 201) {
return;
}
const { id } = await response.json();
await router.push(`/notes/${id}`);
}}
>
<div>
<label>


+ 1
- 2
packages/iceform-next/package.json View File

@@ -13,7 +13,6 @@
"pridepack"
],
"devDependencies": {
"@types/testing-library__jest-dom": "^5.14.9",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.4.3",
@@ -22,6 +21,7 @@
"@types/express": "^4.17.17",
"@types/node": "^18.14.1",
"@types/react": "^18.0.27",
"@types/testing-library__jest-dom": "^5.14.9",
"@vitest/coverage-v8": "^0.33.0",
"eslint": "^8.35.0",
"eslint-config-lxsmnsyc": "^0.5.0",
@@ -69,7 +69,6 @@
"dependencies": {
"@theoryofnekomata/formxtra": "^1.0.3",
"busboy": "^1.6.0",
"fetch-ponyfill": "^7.1.0",
"nookies": "^2.5.2",
"seroval": "^0.9.0"
},


+ 3
- 3
packages/iceform-next/src/client/common.ts View File

@@ -20,7 +20,7 @@ export type AllowedClientMethod = typeof ALLOWED_CLIENT_METHODS[number];

export type NextPage<T = NonNullable<unknown>, U = T> = DefaultNextPage<
T & {
res: NextApiResponse;
req: NextApiRequest;
}, U
res: NextApiResponse;
req: NextApiRequest;
}, U
>

+ 2
- 0
packages/iceform-next/src/client/components/Form.tsx View File

@@ -71,6 +71,8 @@ export const Form = React.forwardRef<FormDerivedElement, FormProps>(({
setServerMethodOverride(false);
}, []);

// TODO csrf token

return (
<FormDerivedElementComponent
{...etcProps}


+ 4
- 3
packages/iceform-next/src/client/hooks/useFormFetch.ts View File

@@ -1,8 +1,8 @@
import * as React from 'react';
import fetchPonyfill from 'fetch-ponyfill';
import { EncTypeSerializerMap, serializeBody, SerializerOptions } from '../../utils/serialization';
import { ENCTYPE_MULTIPART_FORM_DATA } from '../../common/enctypes';
import { AllowedClientMethod, FormDerivedElement } from '../common';
import { METHODS_WITH_BODY } from '../../common/constants';

export interface UseFormFetchParams {
clientAction?: string;
@@ -37,7 +37,6 @@ export const useFormFetch = ({

if (clientAction) {
invalidate?.();
const { fetch } = fetchPonyfill();
const headers: HeadersInit = {
...(clientHeaders ?? {}),
Accept: responseEncType,
@@ -52,7 +51,9 @@ export const useFormFetch = ({
headers,
};

if (!['GET', 'HEAD'].includes(clientMethod.toUpperCase())) {
if (
METHODS_WITH_BODY.includes(clientMethod.toUpperCase() as typeof METHODS_WITH_BODY[number])
) {
fetchInit.body = serializeBody({
form: event.currentTarget,
encType,


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

@@ -1,2 +1,3 @@
export const PREVENT_REDIRECT_FORM_KEY = '__iceform_prevent_redirect' as const;
export const METHOD_FORM_KEY = '__iceform_method' as const;
export const METHODS_WITH_BODY = ['POST', 'PUT', 'PATCH', 'DELETE'] as const;

+ 14
- 7
packages/iceform-next/src/server/action.ts View File

@@ -14,7 +14,7 @@ import {
STATUS_MESSAGE_COOKIE_KEY,
} from '../utils/cookies';
import {
METHOD_FORM_KEY,
METHOD_FORM_KEY, METHODS_WITH_BODY,
PREVENT_REDIRECT_FORM_KEY,
} from '../common/constants';
import {
@@ -34,6 +34,8 @@ export type OnActionFunction<
Params extends ParsedUrlQuery = ParsedUrlQuery,
Preview extends PreviewData = PreviewData,
> = (
actionReq: DefaultNextApiRequest,
actionRes: IceformNextServerResponse,
context: GetServerSidePropsContext<Params, Preview>,
) => Promise<Awaited<ReturnType<GetServerSideProps<Props, Params, Preview>>> | undefined>;

@@ -51,12 +53,13 @@ export interface ActionWrapperOptions {
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;
if (METHODS_WITH_BODY.includes(req.method?.toLowerCase() as typeof METHODS_WITH_BODY[number])) {
reqMut.body = await deserializeBody({
req,
deserializers: options.deserializers,
});
}
return options.fn(reqMut as unknown as DefaultNextApiRequest, res);
};

@@ -109,7 +112,11 @@ export const getServerSideProps = (
}

if (typeof options.onAction === 'function') {
const onActionResult = await options.onAction(ctx);
const onActionResult = await options.onAction(
mockReq,
mockRes,
ctx,
);
if (onActionResult) {
return onActionResult;
}


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

@@ -1,4 +1,3 @@
export const METHODS_WITH_BODY = ['POST', 'PUT', 'PATCH', 'DELETE'] 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


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

@@ -6,7 +6,6 @@ import {
DEFAULT_ENCODING,
DEFAULT_METHOD,
DEFAULT_RESPONSE_STATUS_CODE,
METHODS_WITH_BODY,
} from './constants';
import { getBody } from '../utils/request';
import {
@@ -16,6 +15,7 @@ import {
STATUS_MESSAGE_COOKIE_KEY,
} from '../utils/cookies';
import { ENCTYPE_APPLICATION_JSON, ENCTYPE_APPLICATION_OCTET_STREAM } from '../common/enctypes';
import { METHODS_WITH_BODY } from '../common/constants';

export type DestinationGetServerSideProps<
Props extends Record<string, unknown> = Record<string, unknown>,


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

@@ -6,6 +6,7 @@ import {
ENCTYPE_X_WWW_FORM_URLENCODED,
} from '../common/enctypes';
import { getBody, parseMultipartFormData } from './request';
import { METHOD_FORM_KEY, PREVENT_REDIRECT_FORM_KEY } from '../common/constants';

export type EncTypeSerializer = (data: unknown) => string;

@@ -33,19 +34,31 @@ export const serializeBody = (params: SerializeBodyParams) => {
} = params;

if (encType === ENCTYPE_X_WWW_FORM_URLENCODED) {
return new URLSearchParams(form);
const searchParams = new URLSearchParams(form);
searchParams.delete(METHOD_FORM_KEY);
searchParams.delete(PREVENT_REDIRECT_FORM_KEY);
return searchParams;
}

if (encType === ENCTYPE_MULTIPART_FORM_DATA) {
// type error when provided a submitter element for some reason...
const FormDataUnknown = FormData as unknown as {
new(formElement?: HTMLElement, submitter?: HTMLElement): BodyInit;
new(formElement?: HTMLElement, submitter?: HTMLElement): FormData;
};
return new FormDataUnknown(form, options?.submitter);
const formData = new FormDataUnknown(form, options?.submitter);
formData.delete(METHOD_FORM_KEY);
formData.delete(PREVENT_REDIRECT_FORM_KEY);
return formData;
}

if (typeof serializers[encType] === 'function') {
return serializers[encType](getFormValues(form, options));
const {
[METHOD_FORM_KEY]: _method,
[PREVENT_REDIRECT_FORM_KEY]: _preventRedirect,
...formValues
} = getFormValues(form, options);

return serializers[encType](formValues);
}

throw new Error(`Unsupported encType: ${encType}`);


+ 0
- 38
pnpm-lock.yaml View File

@@ -14,9 +14,6 @@ importers:
busboy:
specifier: ^1.6.0
version: 1.6.0
fetch-ponyfill:
specifier: ^7.1.0
version: 7.1.0
nookies:
specifier: ^2.5.2
version: 2.5.2
@@ -3095,14 +3092,6 @@ packages:
dependencies:
reusify: 1.0.4

/fetch-ponyfill@7.1.0:
resolution: {integrity: sha512-FhbbL55dj/qdVO3YNK7ZEkshvj3eQ7EuIGV2I6ic/2YiocvyWv+7jg2s4AyS0wdRU75s3tA8ZxI/xPigb0v5Aw==}
dependencies:
node-fetch: 2.6.13
transitivePeerDependencies:
- encoding
dev: false

/file-entry-cache@6.0.1:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
engines: {node: ^10.12.0 || >=12.0.0}
@@ -4141,18 +4130,6 @@ packages:
- '@babel/core'
- babel-plugin-macros

/node-fetch@2.6.13:
resolution: {integrity: sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
dependencies:
whatwg-url: 5.0.0
dev: false

/node-releases@2.0.13:
resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==}

@@ -5207,10 +5184,6 @@ packages:
url-parse: 1.5.10
dev: true

/tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
dev: false

/tr46@3.0.0:
resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==}
engines: {node: '>=12'}
@@ -5566,10 +5539,6 @@ packages:
defaults: 1.0.4
dev: true

/webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
dev: false

/webidl-conversions@7.0.0:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'}
@@ -5595,13 +5564,6 @@ packages:
webidl-conversions: 7.0.0
dev: true

/whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
dev: false

/which-boxed-primitive@1.0.2:
resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==}
dependencies:


Loading…
Cancel
Save