Parcourir la source

Implement form actions with resource locations

Process redirects and parse resource locations for both server-side and client-side.
master
TheoryOfNekomata il y a 1 an
Parent
révision
2cfec3e902
14 fichiers modifiés avec 281 ajouts et 63 suppressions
  1. +1
    -1
      README.md
  2. +1
    -0
      packages/iceform-next-sandbox/.gitignore
  3. +3
    -0
      packages/iceform-next-sandbox/src/handlers/note.ts
  4. +2
    -2
      packages/iceform-next-sandbox/src/pages/a/notes/[noteId].ts
  5. +13
    -3
      packages/iceform-next-sandbox/src/pages/a/notes/index.ts
  6. +4
    -1
      packages/iceform-next-sandbox/src/pages/api/notes/index.ts
  7. +3
    -1
      packages/iceform-next-sandbox/src/pages/greet.tsx
  8. +57
    -1
      packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx
  9. +36
    -1
      packages/iceform-next-sandbox/src/pages/notes/index.tsx
  10. +5
    -0
      packages/iceform-next/src/client/hooks/useResponse.ts
  11. +8
    -0
      packages/iceform-next/src/server/constants.ts
  12. +51
    -50
      packages/iceform-next/src/server/index.ts
  13. +93
    -0
      packages/iceform-next/src/server/response.ts
  14. +4
    -3
      packages/iceform-next/src/utils/request.ts

+ 1
- 1
README.md Voir le fichier

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


+ 1
- 0
packages/iceform-next-sandbox/.gitignore Voir le fichier

@@ -33,3 +33,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.db/

+ 3
- 0
packages/iceform-next-sandbox/src/handlers/note.ts Voir le fichier

@@ -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
- 2
packages/iceform-next-sandbox/src/pages/a/notes/[noteId].ts Voir le fichier

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

+ 13
- 3
packages/iceform-next-sandbox/src/pages/a/notes/index.ts Voir le fichier

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

+ 4
- 1
packages/iceform-next-sandbox/src/pages/api/notes/index.ts Voir le fichier

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



+ 3
- 1
packages/iceform-next-sandbox/src/pages/greet.tsx Voir le fichier

@@ -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(() => {


+ 57
- 1
packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx Voir le fichier

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

+ 36
- 1
packages/iceform-next-sandbox/src/pages/notes/index.tsx Voir le fichier

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

+ 5
- 0
packages/iceform-next/src/client/hooks/useResponse.ts Voir le fichier

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

+ 8
- 0
packages/iceform-next/src/server/constants.ts Voir le fichier

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

packages/iceform-next/src/server.ts → packages/iceform-next/src/server/index.ts Voir le fichier

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

+ 93
- 0
packages/iceform-next/src/server/response.ts Voir le fichier

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

+ 4
- 3
packages/iceform-next/src/utils/request.ts Voir le fichier

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



Chargement…
Annuler
Enregistrer