Говорите то, что имеете в виду
REST API общается с помощью двух словарей. Метод говорит, чего хочет клиент: GET — прочитать, POST — создать, PUT — заменить, PATCH — изменить часть, DELETE — удалить. Код состояния говорит, как всё прошло: 2xx — успех, 4xx — ошибка клиента, 5xx — ошибка сервера.
Деталь, которую стоит знать: PUT идемпотентен — отправить его дважды всё равно что один раз. POST — нет: два POST создадут два ресурса.
Правильный метод
Маршрутизатор сопоставляет и метод, и путь, поэтому у каждого глагола свой обработчик. Создание отвечает 201 Created и указывает на новый ресурс заголовком Location; удаление отвечает 204 No Content — успех, о котором нечего сказать, поэтому тело остаётся пустым.
GET, POST и DELETE для одного ресурса
Три глагола, три ответа: список возвращается с кодом 200, создание — с 201 и заголовком Location, удаление — с 204 и пустым телом.
Вывод: программа на TypeScript и захваченный вывод
import { createServer, type IncomingMessage, type ServerResponse } from "node:http"
type RouteHandler = (
req: IncomingMessage,
res: ServerResponse,
params: Record<string, string>,
) => void
interface Route {
method: string
pattern: string
handler: RouteHandler
}
function matchPath(pattern: string, path: string): Record<string, string> | undefined {
const want = pattern.split("/")
const got = path.split("/")
if (want.length !== got.length) return undefined
const params: Record<string, string> = {}
for (let i = 0; i < want.length; i++) {
if (want[i].startsWith("{") && want[i].endsWith("}")) {
params[want[i].slice(1, -1)] = got[i]
} else if (want[i] !== got[i]) {
return undefined
}
}
return params
}
function router(routes: Route[]) {
return (req: IncomingMessage, res: ServerResponse): void => {
const url = new URL(req.url ?? "/", "http://localhost")
for (const route of routes) {
const params = matchPath(route.pattern, url.pathname)
if (params !== undefined && route.method === req.method) {
route.handler(req, res, params)
return
}
}
res.writeHead(404).end("не найдено\n")
}
}
const routes: Route[] = [
{
method: "GET",
pattern: "/notes",
handler: (req, res) => {
res.end("заметки: покупки, идеи\n")
},
},
{
method: "POST",
pattern: "/notes",
handler: (req, res) => {
// 201 Created, и заголовок Location указывает на новый ресурс.
res.writeHead(201, { Location: "/notes/3" }).end("создано\n")
},
},
{
method: "DELETE",
pattern: "/notes/{id}",
handler: (req, res) => {
// 204 No Content: успех, о котором нечего сказать.
res.writeHead(204).end()
},
},
]
async function main(): Promise<void> {
const server = createServer(router(routes))
await new Promise<void>(resolve => server.listen(0, () => resolve()))
const base = `http://localhost:${(server.address() as { port: number }).port}`
const list = await fetch(`${base}/notes`)
console.log("GET /notes ->", list.status, (await list.text()).trim())
const created = await fetch(`${base}/notes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: '{"text": "позвонить Ben"}',
})
console.log("POST /notes ->", created.status, "Location:", created.headers.get("location"))
const removed = await fetch(`${base}/notes/3`, { method: "DELETE" })
console.log("DELETE /notes/3 ->", removed.status, "(нет содержимого)")
server.close()
}
main()
Правильный код состояния
Ошибки тоже заслуживают точности: отсутствующий ресурс — это 404 Not Found, с телом ошибки в формате JSON, чтобы его могли прочитать машины. Неверный глагол для известного пути — это 405 Method Not Allowed.
Здесь никто не отправит этот 405 за вас. Маршрутизатор проверяет обе части отдельно: если путь какого-то маршрута совпал, но метод не совпал ни у одного, он отвечает 405 и добавляет заголовок Allow со списком глаголов, которые сработали бы.
200, 404 и 405
Существующая заметка возвращает 200 с данными, отсутствующая — 404 с ошибкой в формате JSON, а DELETE для маршрута, поддерживающего только GET, отклоняется самим маршрутизатором с кодом 405.
Вывод: программа на TypeScript и захваченный вывод
import { createServer, type IncomingMessage, type ServerResponse } from "node:http"
type RouteHandler = (
req: IncomingMessage,
res: ServerResponse,
params: Record<string, string>,
) => void
interface Route {
method: string
pattern: string
handler: RouteHandler
}
function matchPath(pattern: string, path: string): Record<string, string> | undefined {
const want = pattern.split("/")
const got = path.split("/")
if (want.length !== got.length) return undefined
const params: Record<string, string> = {}
for (let i = 0; i < want.length; i++) {
if (want[i].startsWith("{") && want[i].endsWith("}")) {
params[want[i].slice(1, -1)] = got[i]
} else if (want[i] !== got[i]) {
return undefined
}
}
return params
}
// Путь совпал, а метод — нет: отвечаем 405 и сообщаем, что разрешено.
function router(routes: Route[]) {
return (req: IncomingMessage, res: ServerResponse): void => {
const url = new URL(req.url ?? "/", "http://localhost")
const allowed: string[] = []
for (const route of routes) {
const params = matchPath(route.pattern, url.pathname)
if (params === undefined) continue
if (route.method === req.method) {
route.handler(req, res, params)
return
}
allowed.push(route.method)
}
if (allowed.length > 0) {
res.writeHead(405, { Allow: allowed.join(", ") }).end()
return
}
res.writeHead(404).end()
}
}
const notes = new Map<string, string>([["1", "список покупок"]])
const routes: Route[] = [
{
method: "GET",
pattern: "/notes/{id}",
handler: (req, res, params) => {
res.setHeader("Content-Type", "application/json")
const text = notes.get(params.id)
if (text === undefined) {
res.writeHead(404).end(JSON.stringify({ error: "заметка не найдена" }))
return
}
res.end(JSON.stringify({ id: params.id, text }))
},
},
]
async function main(): Promise<void> {
const server = createServer(router(routes))
await new Promise<void>(resolve => server.listen(0, () => resolve()))
const base = `http://localhost:${(server.address() as { port: number }).port}`
for (const path of ["/notes/1", "/notes/99"]) {
const res = await fetch(base + path)
console.log("GET ", path.padEnd(9), "->", res.status, await res.text())
}
const refused = await fetch(`${base}/notes/1`, { method: "DELETE" })
console.log("DELETE", "/notes/1".padEnd(9), "->", refused.status, refused.statusText)
server.close()
}
main()