@@ -8,13 +8,13 @@ Powered by [formxtra](https://code.modal.sh/TheoryOfNekomata/formxtra). | |||||
* Remove dependence from client-side JavaScript (graceful degradataion) | * Remove dependence from client-side JavaScript (graceful degradataion) | ||||
* Allow scriptability of forms | * Allow scriptability of forms | ||||
* Improved accessibility | |||||
* Improve accessibility | |||||
## Features | ## Features | ||||
* Emulate client-side behavior in a purely server-side manner | * 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 | ## Usage | ||||
@@ -124,8 +124,10 @@ In theory, any API route may have a corresponding action route. | |||||
## TODO | ## TODO | ||||
- [X] Content negotiation (custom request data) | - [X] Content negotiation (custom request data) | ||||
- [ ] `<form method="dialog">` (on hold, see https://github.com/whatwg/html/issues/9625) | |||||
- [ ] Tests | - [ ] Tests | ||||
- [ ] Form with redirects | - [ ] Form with redirects | ||||
- [ ] Form with files | - [ ] Form with files | ||||
- [ ] Documentation | - [ ] Documentation | ||||
- [ ] Remix support | - [ ] Remix support | ||||
- [ ] `accept-charset=""` attribute support |
@@ -1,8 +1,14 @@ | |||||
import {NextApiHandler} from 'next'; | |||||
import { NextApiHandler } from 'next'; | |||||
export const greet: NextApiHandler = async (req, res) => { | export const greet: NextApiHandler = async (req, res) => { | ||||
const { name } = req.body; | 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'); | |||||
}; | }; |
@@ -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'); | |||||
}; |
@@ -4,6 +4,9 @@ import {greet} from '@/handlers/greet'; | |||||
const ActionGreetPage: NextPage = () => null; | 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, | |||||
}; |
@@ -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, | |||||
}; |
@@ -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, | |||||
}; |
@@ -3,6 +3,9 @@ import { greet } from '@/handlers/greet'; | |||||
const handler = Iceform.action.wrapApiHandler({ fn: 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, | |||||
}; |
@@ -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, | |||||
}; |
@@ -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, | |||||
}; |
@@ -0,0 +1 @@ | |||||
// TODO view note page |
@@ -0,0 +1 @@ | |||||
// TODO view all notes/add notes page |
@@ -13,16 +13,16 @@ | |||||
"pridepack" | "pridepack" | ||||
], | ], | ||||
"devDependencies": { | "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/busboy": "^1.5.1", | ||||
"@types/cookie": "^0.5.2", | "@types/cookie": "^0.5.2", | ||||
"@types/express": "^4.17.17", | "@types/express": "^4.17.17", | ||||
"@types/node": "^18.14.1", | "@types/node": "^18.14.1", | ||||
"@types/react": "^18.0.27", | "@types/react": "^18.0.27", | ||||
"@vitest/coverage-v8": "^0.33.0", | |||||
"@vitest/coverage-v8": "^0.33.0", | |||||
"eslint": "^8.35.0", | "eslint": "^8.35.0", | ||||
"eslint-config-lxsmnsyc": "^0.5.0", | "eslint-config-lxsmnsyc": "^0.5.0", | ||||
"express": "^4.18.2", | "express": "^4.18.2", | ||||
@@ -34,7 +34,7 @@ | |||||
"react-test-renderer": "^18.2.0", | "react-test-renderer": "^18.2.0", | ||||
"tslib": "^2.5.0", | "tslib": "^2.5.0", | ||||
"typescript": "^4.9.5", | "typescript": "^4.9.5", | ||||
"vitest": "^0.34.1" | |||||
"vitest": "^0.34.1" | |||||
}, | }, | ||||
"peerDependencies": { | "peerDependencies": { | ||||
"next": "13.4.19", | "next": "13.4.19", | ||||
@@ -1,5 +1,5 @@ | |||||
import { NextPage as DefaultNextPage } from 'next/types'; | import { NextPage as DefaultNextPage } from 'next/types'; | ||||
import { NextApiRequest, NextApiResponse } from '../common'; | |||||
import { NextApiRequest, NextApiResponse } from '../common/types'; | |||||
export const FormDerivedElementComponent = 'form' as const; | export const FormDerivedElementComponent = 'form' as const; | ||||
@@ -2,7 +2,7 @@ import * as React from 'react'; | |||||
import { | import { | ||||
ENCTYPE_APPLICATION_JSON, | ENCTYPE_APPLICATION_JSON, | ||||
ENCTYPE_MULTIPART_FORM_DATA, | ENCTYPE_MULTIPART_FORM_DATA, | ||||
} from '../../common'; | |||||
} from '../../common/enctypes'; | |||||
import { | import { | ||||
DEFAULT_ENCTYPE_SERIALIZERS, | DEFAULT_ENCTYPE_SERIALIZERS, | ||||
EncTypeSerializerMap, | EncTypeSerializerMap, | ||||
@@ -1,7 +1,7 @@ | |||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import fetchPonyfill from 'fetch-ponyfill'; | import fetchPonyfill from 'fetch-ponyfill'; | ||||
import { EncTypeSerializerMap, serializeBody, SerializerOptions } from '../../utils/serialization'; | 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'; | import { AllowedClientMethod, FormDerivedElement } from '../common'; | ||||
export interface UseFormFetchParams { | export interface UseFormFetchParams { | ||||
@@ -1,7 +1,12 @@ | |||||
import * as React from 'react'; | 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<Response | undefined>( | const [response, setResponse] = React.useState<Response | undefined>( | ||||
res.body ? new Response(res.body as unknown as BodyInit) : undefined, | res.body ? new Response(res.body as unknown as BodyInit) : undefined, | ||||
); | ); | ||||
@@ -1,13 +1,3 @@ | |||||
import { | |||||
NextApiRequest as DefaultNextApiRequest, | |||||
} from 'next'; | |||||
export type NextApiRequest = Pick<DefaultNextApiRequest, 'query' | 'body'>; | |||||
export interface NextApiResponse { | |||||
body?: unknown; | |||||
} | |||||
export const ENCTYPE_APPLICATION_JSON = 'application/json' as const; | export const ENCTYPE_APPLICATION_JSON = 'application/json' as const; | ||||
export const ENCTYPE_APPLICATION_OCTET_STREAM = 'application/octet-stream' as const; | export const ENCTYPE_APPLICATION_OCTET_STREAM = 'application/octet-stream' as const; |
@@ -0,0 +1,9 @@ | |||||
import { | |||||
NextApiRequest as DefaultNextApiRequest, | |||||
} from 'next'; | |||||
export type NextApiRequest = Pick<DefaultNextApiRequest, 'query' | 'body'>; | |||||
export interface NextApiResponse { | |||||
body?: unknown; | |||||
} |
@@ -1,3 +1,3 @@ | |||||
export type * from './common'; | |||||
export * from './common/types'; | |||||
export * from './client'; | export * from './client'; | ||||
export * from './server'; | export * from './server'; |
@@ -9,9 +9,11 @@ import { deserialize, serialize } from 'seroval'; | |||||
import { | import { | ||||
NextApiResponse, | NextApiResponse, | ||||
NextApiRequest, | NextApiRequest, | ||||
} from './common/types'; | |||||
import { | |||||
ENCTYPE_APPLICATION_JSON, | ENCTYPE_APPLICATION_JSON, | ||||
ENCTYPE_APPLICATION_OCTET_STREAM, | ENCTYPE_APPLICATION_OCTET_STREAM, | ||||
} from './common'; | |||||
} from './common/enctypes'; | |||||
import { getBody } from './utils/request'; | import { getBody } from './utils/request'; | ||||
import { | import { | ||||
CookieManager, | CookieManager, | ||||
@@ -4,7 +4,7 @@ import { | |||||
ENCTYPE_APPLICATION_JSON, ENCTYPE_APPLICATION_OCTET_STREAM, | ENCTYPE_APPLICATION_JSON, ENCTYPE_APPLICATION_OCTET_STREAM, | ||||
ENCTYPE_MULTIPART_FORM_DATA, | ENCTYPE_MULTIPART_FORM_DATA, | ||||
ENCTYPE_X_WWW_FORM_URLENCODED, | ENCTYPE_X_WWW_FORM_URLENCODED, | ||||
} from '../common'; | |||||
} from '../common/enctypes'; | |||||
import { getBody, parseMultipartFormData } from './request'; | import { getBody, parseMultipartFormData } from './request'; | ||||
export type EncTypeSerializer = (data: unknown) => string; | export type EncTypeSerializer = (data: unknown) => string; | ||||