diff --git a/README.md b/README.md index cb6504f..d980029 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ Powered by [formxtra](https://code.modal.sh/TheoryOfNekomata/formxtra). * Remove dependence from client-side JavaScript (graceful degradataion) * Allow scriptability of forms -* Improved accessibility +* Improve accessibility ## Features * Emulate client-side behavior in a purely server-side manner -* HTTP compliant (respects status code semantics) -* Easy setup (adapts to how receiving requests are made in the framework of choice) +* Observe HTTP compliance (respects status code semantics) +* Allow easy setup (adapts to how receiving requests are made in the framework of choice) ## Usage @@ -124,8 +124,10 @@ In theory, any API route may have a corresponding action route. ## TODO - [X] Content negotiation (custom request data) +- [ ] `
` (on hold, see https://github.com/whatwg/html/issues/9625) - [ ] Tests - [ ] Form with redirects - [ ] Form with files - [ ] Documentation - [ ] Remix support +- [ ] `accept-charset=""` attribute support diff --git a/packages/iceform-next-sandbox/src/handlers/greet.ts b/packages/iceform-next-sandbox/src/handlers/greet.ts index 86072da..30a2565 100644 --- a/packages/iceform-next-sandbox/src/handlers/greet.ts +++ b/packages/iceform-next-sandbox/src/handlers/greet.ts @@ -1,8 +1,14 @@ -import {NextApiHandler} from 'next'; +import { NextApiHandler } from 'next'; export const greet: NextApiHandler = async (req, res) => { const { name } = req.body; - res.status(202).json({ - name: `Hello ${name}`, - }); + + if (req.method?.toLowerCase() === 'post') { + res.status(200).json({ + name: `Hello ${name}`, + }); + return; + } + + res.status(405).send('Method Not Allowed'); }; diff --git a/packages/iceform-next-sandbox/src/handlers/note.ts b/packages/iceform-next-sandbox/src/handlers/note.ts new file mode 100644 index 0000000..93d9d66 --- /dev/null +++ b/packages/iceform-next-sandbox/src/handlers/note.ts @@ -0,0 +1,266 @@ +import { NextApiHandler } from 'next'; +import * as crypto from 'crypto'; +import * as fs from 'fs/promises'; + +const ensureDatabaseDirectory = async () => { + try { + await fs.mkdir('.db'); + } catch (errRaw) { + const err = errRaw as NodeJS.ErrnoException; + + if (err.code !== 'EEXIST') { + throw new Error('Failed to create .db directory'); + } + + // noop, .db directory already exists + } +}; + +const getSingleNote: NextApiHandler = async (req, res) => { + const { noteId } = req.query; + + if (typeof noteId !== 'string') { + res.status(400).send('Bad Request'); + return; + } + + const dataRaw = await fs.readFile('.db/notes.jsonl', { + encoding: 'utf-8', + }); + + const data = dataRaw + .split('\n') + .filter((s) => s.trim().length > 0) + .map((line) => JSON.parse(line)); + + const note = data.find((note) => note.id === noteId); + + if (typeof note === 'undefined') { + res.status(404).send('Not Found'); + return; + } + + res.status(200).json(note); +}; + +const patchNote: NextApiHandler = async (req, res) => { + const { noteId } = req.query; + + if (typeof noteId !== 'string') { + res.status(400).send('Bad Request'); + return; + } + + const { title, content } = req.body; + + const dataRaw = await fs.readFile('.db/notes.jsonl', { + encoding: 'utf-8', + }); + + const data = dataRaw + .split('\n') + .filter((s) => s.trim().length > 0) + .map((line) => JSON.parse(line)); + + const noteIndex = data.findIndex((note) => note.id === noteId); + + if (noteIndex === -1) { + res.status(404).send('Not Found'); + return; + } + + const note = data[noteIndex]; + + const updatedNote = { + ...note, + title: title ?? note.title, + content: content ?? note.content, + }; + + data[noteIndex] = updatedNote; + + try { + await ensureDatabaseDirectory(); + await fs.writeFile( + '.db/notes.jsonl', + data.map((note) => JSON.stringify(note)).join('\n'), + ); + } catch (errRaw) { + const err = errRaw as Error; + res.status(500).send(err.message); + return; + } + + res.status(200).json(updatedNote); +}; + +const emplaceNote: NextApiHandler = async (req, res) => { + const { noteId } = req.query; + + if (typeof noteId !== 'string') { + res.status(400).send('Bad Request'); + return; + } + + const dataRaw = await fs.readFile('.db/notes.jsonl', { + encoding: 'utf-8', + }); + + const data = dataRaw + .split('\n') + .filter((s) => s.trim().length > 0) + .map((line) => JSON.parse(line)); + + const noteIndex = data.findIndex((note) => note.id === noteId); + + if (noteIndex === -1) { + return createNote({ + basePath: '/api/notes', + })(req, res); + } + + return patchNote(req, res); +} + +const deleteNote: NextApiHandler = async (req, res) => { + const { noteId } = req.query; + + if (typeof noteId !== 'string') { + res.status(400).send('Bad Request'); + return; + } + + const dataRaw = await fs.readFile('.db/notes.jsonl', { + encoding: 'utf-8', + }); + + const data = dataRaw + .split('\n') + .filter((s) => s.trim().length > 0) + .map((line) => JSON.parse(line)); + + const noteIndex = data.findIndex((note) => note.id === noteId); + + if (noteIndex === -1) { + res.status(404).send('Not Found'); + return; + } + + data.splice(noteIndex, 1); + + try { + await ensureDatabaseDirectory(); + await fs.writeFile( + '.db/notes.jsonl', + data.map((note) => JSON.stringify(note)).join('\n'), + ); + } catch (errRaw) { + const err = errRaw as Error; + res.status(500).send(err.message); + return; + } + + res.status(204).end(); +} + +export const noteResource: NextApiHandler = async (req, res) => { + switch (req.method?.toLowerCase()) { + case 'get': + return getSingleNote(req, res); + case 'patch': + return patchNote(req, res); + case 'put': + return emplaceNote(req, res); + case 'delete': + return deleteNote(req, res); + default: + break; + } + + res.status(405).send('Method not allowed'); +}; + +export interface NoteCollectionParams { + basePath: string; +} + +const createNote = (params: NoteCollectionParams): NextApiHandler => async (req, res) => { + const { title, content } = req.body; + + if (typeof title !== 'string' || typeof content !== 'string') { + res.status(400).send('Bad Request'); + return; + } + + const newId = crypto.randomUUID(); + + try { + await ensureDatabaseDirectory(); + await fs.writeFile( + '.db/notes.jsonl', + `${JSON.stringify({ + id: newId, + title, + content, + })}\n`, + { + flag: 'a', + }, + ); + } catch (errRaw) { + const err = errRaw as Error; + res.status(500).send(err.message); + return; + } + + // how to genericize the location URL? + res.setHeader('Location', `${params.basePath}/${newId}`); + + res.status(201).json({ + id: newId, + title, + content, + }); +}; + +const getMultipleNotes: NextApiHandler = async (req, res) => { + const { ids, q } = req.query; + const dataRaw = await fs.readFile('.db/notes.jsonl', { + encoding: 'utf-8', + }); + + const data = dataRaw + .split('\n') + .filter((s) => s.trim().length > 0) + .map((line) => JSON.parse(line)); + + let filteredData = data; + + if (typeof ids !== 'undefined') { + const idsArray = Array.isArray(ids) ? ids : [ids]; + filteredData = data.filter((note) => idsArray.includes(note.id)); + } + + if (typeof q === 'string') { + const qToLowerCase = q.toLowerCase(); + filteredData = data.filter((note) => ( + note.title.toLowerCase().includes(qToLowerCase) + || note.content.toLowerCase().includes(qToLowerCase) + )); + } + + res.status(200).json(filteredData); +}; + +export const noteCollection = (params: NoteCollectionParams): NextApiHandler =>async (req, res) => { + switch (req.method?.toLowerCase()) { + case 'post': + return createNote(params)(req, res); + case 'get': + return getMultipleNotes(req, res); + default: + break; + } + + res.status(405).send('Method not allowed'); +}; diff --git a/packages/iceform-next-sandbox/src/pages/a/greet.ts b/packages/iceform-next-sandbox/src/pages/a/greet.ts index 544394b..b4411c0 100644 --- a/packages/iceform-next-sandbox/src/pages/a/greet.ts +++ b/packages/iceform-next-sandbox/src/pages/a/greet.ts @@ -4,6 +4,9 @@ import {greet} from '@/handlers/greet'; const ActionGreetPage: NextPage = () => null; -export default ActionGreetPage; +const getServerSideProps = Iceform.action.getServerSideProps({ fn: greet }); -export const getServerSideProps = Iceform.action.getServerSideProps({ fn: greet }); +export { + getServerSideProps, + ActionGreetPage as default, +}; diff --git a/packages/iceform-next-sandbox/src/pages/a/notes/[noteId].ts b/packages/iceform-next-sandbox/src/pages/a/notes/[noteId].ts new file mode 100644 index 0000000..0d28175 --- /dev/null +++ b/packages/iceform-next-sandbox/src/pages/a/notes/[noteId].ts @@ -0,0 +1,14 @@ +import {NextPage} from 'next'; +import * as Iceform from '@modal-sh/iceform-next'; +import {noteResource} from '@/handlers/note'; + +const ActionNotesIndexPage: NextPage = () => null; + +const getServerSideProps = Iceform.action.getServerSideProps({ + fn: noteResource, +}); + +export { + getServerSideProps, + ActionNotesIndexPage as default, +}; diff --git a/packages/iceform-next-sandbox/src/pages/a/notes/index.ts b/packages/iceform-next-sandbox/src/pages/a/notes/index.ts new file mode 100644 index 0000000..cfd6e2a --- /dev/null +++ b/packages/iceform-next-sandbox/src/pages/a/notes/index.ts @@ -0,0 +1,16 @@ +import {NextPage} from 'next'; +import * as Iceform from '@modal-sh/iceform-next'; +import {noteCollection} from '@/handlers/note'; + +const ActionNotesIndexPage: NextPage = () => null; + +const getServerSideProps = Iceform.action.getServerSideProps({ + fn: noteCollection({ + basePath: '/api/notes' + }), +}); + +export { + getServerSideProps, + ActionNotesIndexPage as default, +}; diff --git a/packages/iceform-next-sandbox/src/pages/api/greet.ts b/packages/iceform-next-sandbox/src/pages/api/greet.ts index 6c09d1c..1695594 100644 --- a/packages/iceform-next-sandbox/src/pages/api/greet.ts +++ b/packages/iceform-next-sandbox/src/pages/api/greet.ts @@ -3,6 +3,9 @@ import { greet } from '@/handlers/greet'; const handler = Iceform.action.wrapApiHandler({ fn: greet }); -export const config = Iceform.action.getApiConfig(); +const config = Iceform.action.getApiConfig(); -export default handler; +export { + config, + handler as default, +}; diff --git a/packages/iceform-next-sandbox/src/pages/api/notes/[noteId].ts b/packages/iceform-next-sandbox/src/pages/api/notes/[noteId].ts new file mode 100644 index 0000000..f3fc1c8 --- /dev/null +++ b/packages/iceform-next-sandbox/src/pages/api/notes/[noteId].ts @@ -0,0 +1,13 @@ +import * as Iceform from '@modal-sh/iceform-next'; +import { noteResource } from '@/handlers/note'; + +const handler = Iceform.action.wrapApiHandler({ + fn: noteResource, +}); + +const config = Iceform.action.getApiConfig(); + +export { + config, + handler as default, +}; diff --git a/packages/iceform-next-sandbox/src/pages/api/notes/index.ts b/packages/iceform-next-sandbox/src/pages/api/notes/index.ts new file mode 100644 index 0000000..249cbac --- /dev/null +++ b/packages/iceform-next-sandbox/src/pages/api/notes/index.ts @@ -0,0 +1,15 @@ +import * as Iceform from '@modal-sh/iceform-next'; +import { noteCollection } from '@/handlers/note'; + +const handler = Iceform.action.wrapApiHandler({ + fn: noteCollection({ + basePath: '/api/notes' + }), +}); + +const config = Iceform.action.getApiConfig(); + +export { + config, + handler as default, +}; diff --git a/packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx b/packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx new file mode 100644 index 0000000..b6511b8 --- /dev/null +++ b/packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx @@ -0,0 +1 @@ +// TODO view note page diff --git a/packages/iceform-next-sandbox/src/pages/notes/index.tsx b/packages/iceform-next-sandbox/src/pages/notes/index.tsx new file mode 100644 index 0000000..fa51247 --- /dev/null +++ b/packages/iceform-next-sandbox/src/pages/notes/index.tsx @@ -0,0 +1 @@ +// TODO view all notes/add notes page diff --git a/packages/iceform-next/package.json b/packages/iceform-next/package.json index 065c2be..b221916 100644 --- a/packages/iceform-next/package.json +++ b/packages/iceform-next/package.json @@ -13,16 +13,16 @@ "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", + "@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", "@types/busboy": "^1.5.1", "@types/cookie": "^0.5.2", "@types/express": "^4.17.17", "@types/node": "^18.14.1", "@types/react": "^18.0.27", - "@vitest/coverage-v8": "^0.33.0", + "@vitest/coverage-v8": "^0.33.0", "eslint": "^8.35.0", "eslint-config-lxsmnsyc": "^0.5.0", "express": "^4.18.2", @@ -34,7 +34,7 @@ "react-test-renderer": "^18.2.0", "tslib": "^2.5.0", "typescript": "^4.9.5", - "vitest": "^0.34.1" + "vitest": "^0.34.1" }, "peerDependencies": { "next": "13.4.19", diff --git a/packages/iceform-next/src/client/common.ts b/packages/iceform-next/src/client/common.ts index d3ec078..9e627f6 100644 --- a/packages/iceform-next/src/client/common.ts +++ b/packages/iceform-next/src/client/common.ts @@ -1,5 +1,5 @@ import { NextPage as DefaultNextPage } from 'next/types'; -import { NextApiRequest, NextApiResponse } from '../common'; +import { NextApiRequest, NextApiResponse } from '../common/types'; export const FormDerivedElementComponent = 'form' as const; diff --git a/packages/iceform-next/src/client/components/Form.tsx b/packages/iceform-next/src/client/components/Form.tsx index 8ecdefa..d2def22 100644 --- a/packages/iceform-next/src/client/components/Form.tsx +++ b/packages/iceform-next/src/client/components/Form.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { ENCTYPE_APPLICATION_JSON, ENCTYPE_MULTIPART_FORM_DATA, -} from '../../common'; +} from '../../common/enctypes'; import { DEFAULT_ENCTYPE_SERIALIZERS, EncTypeSerializerMap, diff --git a/packages/iceform-next/src/client/hooks/useFormFetch.ts b/packages/iceform-next/src/client/hooks/useFormFetch.ts index a258195..80417a5 100644 --- a/packages/iceform-next/src/client/hooks/useFormFetch.ts +++ b/packages/iceform-next/src/client/hooks/useFormFetch.ts @@ -1,7 +1,7 @@ import * as React from 'react'; import fetchPonyfill from 'fetch-ponyfill'; import { EncTypeSerializerMap, serializeBody, SerializerOptions } from '../../utils/serialization'; -import { ENCTYPE_MULTIPART_FORM_DATA } from '../../common'; +import { ENCTYPE_MULTIPART_FORM_DATA } from '../../common/enctypes'; import { AllowedClientMethod, FormDerivedElement } from '../common'; export interface UseFormFetchParams { diff --git a/packages/iceform-next/src/client/hooks/useResponse.ts b/packages/iceform-next/src/client/hooks/useResponse.ts index 57e9bd4..eb1fd38 100644 --- a/packages/iceform-next/src/client/hooks/useResponse.ts +++ b/packages/iceform-next/src/client/hooks/useResponse.ts @@ -1,7 +1,12 @@ import * as React from 'react'; -import { NextApiResponse } from '../../common'; +import { NextApiResponse } from '../../common/types'; -export const useResponse = (res: NextApiResponse) => { +export interface UseResponseParams { + res: NextApiResponse; +} + +export const useResponse = (params: UseResponseParams) => { + const { res } = params; const [response, setResponse] = React.useState( res.body ? new Response(res.body as unknown as BodyInit) : undefined, ); diff --git a/packages/iceform-next/src/common.ts b/packages/iceform-next/src/common/enctypes.ts similarity index 61% rename from packages/iceform-next/src/common.ts rename to packages/iceform-next/src/common/enctypes.ts index ad10f35..bef2397 100644 --- a/packages/iceform-next/src/common.ts +++ b/packages/iceform-next/src/common/enctypes.ts @@ -1,13 +1,3 @@ -import { - NextApiRequest as DefaultNextApiRequest, -} from 'next'; - -export type NextApiRequest = Pick; - -export interface NextApiResponse { - body?: unknown; -} - export const ENCTYPE_APPLICATION_JSON = 'application/json' as const; export const ENCTYPE_APPLICATION_OCTET_STREAM = 'application/octet-stream' as const; diff --git a/packages/iceform-next/src/common/types.ts b/packages/iceform-next/src/common/types.ts new file mode 100644 index 0000000..30e3433 --- /dev/null +++ b/packages/iceform-next/src/common/types.ts @@ -0,0 +1,9 @@ +import { + NextApiRequest as DefaultNextApiRequest, +} from 'next'; + +export type NextApiRequest = Pick; + +export interface NextApiResponse { + body?: unknown; +} diff --git a/packages/iceform-next/src/index.ts b/packages/iceform-next/src/index.ts index c26e119..07228d9 100644 --- a/packages/iceform-next/src/index.ts +++ b/packages/iceform-next/src/index.ts @@ -1,3 +1,3 @@ -export type * from './common'; +export * from './common/types'; export * from './client'; export * from './server'; diff --git a/packages/iceform-next/src/server.ts b/packages/iceform-next/src/server.ts index 9b2bb4c..7e7f374 100644 --- a/packages/iceform-next/src/server.ts +++ b/packages/iceform-next/src/server.ts @@ -9,9 +9,11 @@ import { deserialize, serialize } from 'seroval'; import { NextApiResponse, NextApiRequest, +} from './common/types'; +import { ENCTYPE_APPLICATION_JSON, ENCTYPE_APPLICATION_OCTET_STREAM, -} from './common'; +} from './common/enctypes'; import { getBody } from './utils/request'; import { CookieManager, diff --git a/packages/iceform-next/src/utils/serialization.ts b/packages/iceform-next/src/utils/serialization.ts index bbc7f7f..e4d7afc 100644 --- a/packages/iceform-next/src/utils/serialization.ts +++ b/packages/iceform-next/src/utils/serialization.ts @@ -4,7 +4,7 @@ import { ENCTYPE_APPLICATION_JSON, ENCTYPE_APPLICATION_OCTET_STREAM, ENCTYPE_MULTIPART_FORM_DATA, ENCTYPE_X_WWW_FORM_URLENCODED, -} from '../common'; +} from '../common/enctypes'; import { getBody, parseMultipartFormData } from './request'; export type EncTypeSerializer = (data: unknown) => string;