Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ what it changes for someone who uses the API. CI checks that it did.

## Unreleased

- `GET /tasks?done=true` or `?done=false` lists only the tasks that are done, or only those that are not. Any other value of `done` answers 400. (#8)
- `DELETE /tasks/done` removes every task that is done, and says how many it removed. (#6)
- `PATCH /tasks/:id` renames a task, body `{"title": "..."}`, and answers with the task. (#5) (#7)
- `GET /tasks/count` says how many tasks there are, and how many are done.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ and in an adopter's first run. It is small on purpose, so a job is cheap and fas
| Method | Path | What it does |
|---|---|---|
| GET | `/health` | Says the server is up |
| GET | `/tasks` | Lists every task |
| GET | `/tasks` | Lists every task. Takes `?done=true` or `?done=false` to list only the tasks that are done, or only those that are not |
| POST | `/tasks` | Adds a task, body `{"title": "..."}` |
| GET | `/tasks/count` | Says how many tasks there are, and how many are done |
| DELETE | `/tasks/done` | Removes every task that is done, and says how many |
Expand Down
15 changes: 12 additions & 3 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { TaskList, ValidationError } from './tasks.ts';
* The HTTP API over a task list:
*
* GET /health is the server up
* GET /tasks every task
* GET /tasks every task, or with ?done=true or ?done=false only those that are or are not done
* POST /tasks add a task, body {"title": "..."}
* GET /tasks/count how many tasks, and how many are done
* DELETE /tasks/done remove every task that is done, and say how many
Expand All @@ -24,7 +24,7 @@ export function createApp(tasks: TaskList = new TaskList()): Server {
}

async function handle(tasks: TaskList, request: IncomingMessage, response: ServerResponse): Promise<void> {
const { pathname } = new URL(request.url ?? '/', 'http://localhost');
const { pathname, searchParams } = new URL(request.url ?? '/', 'http://localhost');
const parts = pathname.split('/').filter(Boolean);
const method = request.method ?? 'GET';

Expand All @@ -33,7 +33,7 @@ async function handle(tasks: TaskList, request: IncomingMessage, response: Serve
if (parts[0] !== 'tasks') return send(response, 404, { error: 'not found' });

if (parts.length === 1) {
if (method === 'GET') return send(response, 200, { tasks: tasks.all() });
if (method === 'GET') return send(response, 200, { tasks: tasks.all(doneFilter(searchParams)) });
if (method === 'POST') return send(response, 201, tasks.add((await body(request)).title));
return send(response, 405, { error: 'method not allowed' });
}
Expand All @@ -59,6 +59,15 @@ function found(response: ServerResponse, task: unknown): void {
else send(response, 200, task);
}

/** The `done` query parameter: undefined when it is absent. Throws when it is not exactly one `true` or `false`. */
function doneFilter(searchParams: URLSearchParams): boolean | undefined {
const values = searchParams.getAll('done');
if (values.length === 0) return undefined;
if (values.length === 1 && values[0] === 'true') return true;
if (values.length === 1 && values[0] === 'false') return false;
throw new ValidationError('done must be true or false');
}

async function body(request: IncomingMessage): Promise<Record<string, unknown>> {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(chunk as Buffer);
Expand Down
6 changes: 4 additions & 2 deletions src/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ export class TaskList {
private readonly tasks = new Map<number, Task>();
private nextId = 1;

all(): Task[] {
return [...this.tasks.values()];
/** Every task, in the order they were added. Given `done`, only the tasks that are, or are not, done. */
all(done?: boolean): Task[] {
const tasks = [...this.tasks.values()];
return done === undefined ? tasks : tasks.filter((task) => task.done === done);
}

get(id: number): Task | undefined {
Expand Down
55 changes: 55 additions & 0 deletions test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,61 @@ describe('the HTTP API', () => {
await call('DELETE', `/tasks/${id}`);
});

describe('listing tasks by whether they are done', () => {
let first: number;
let open: number;
let third: number;

before(async () => {
first = (await call('POST', '/tasks', { title: 'Done one' })).json!.id as number;
open = (await call('POST', '/tasks', { title: 'Still open' })).json!.id as number;
third = (await call('POST', '/tasks', { title: 'Done two' })).json!.id as number;
await call('POST', `/tasks/${first}/complete`);
await call('POST', `/tasks/${third}/complete`);
});

after(async () => {
for (const id of [first, open, third]) await call('DELETE', `/tasks/${id}`);
});

const mine = (json: Record<string, unknown> | undefined) =>
(json!.tasks as { id: number }[]).filter((task) => [first, open, third].includes(task.id));

it('lists only the tasks that are done with ?done=true', async () => {
const listed = await call('GET', '/tasks?done=true');
assert.equal(listed.status, 200);
assert.ok((listed.json!.tasks as { done: boolean }[]).every((task) => task.done));
assert.deepEqual(mine(listed.json), [
{ id: first, title: 'Done one', done: true },
{ id: third, title: 'Done two', done: true },
]);
});

it('lists only the tasks that are not done with ?done=false', async () => {
const listed = await call('GET', '/tasks?done=false');
assert.equal(listed.status, 200);
assert.ok((listed.json!.tasks as { done: boolean }[]).every((task) => !task.done));
assert.deepEqual(mine(listed.json), [{ id: open, title: 'Still open', done: false }]);
});

it('lists every task without done', async () => {
const listed = await call('GET', '/tasks');
assert.equal(listed.status, 200);
assert.deepEqual(mine(listed.json), [
{ id: first, title: 'Done one', done: true },
{ id: open, title: 'Still open', done: false },
{ id: third, title: 'Done two', done: true },
]);
});

it('answers 400 and lists nothing for any other value of done', async () => {
const refused = { status: 400, json: { error: 'done must be true or false' } };
for (const query of ['done=yes', 'done=1', 'done=TRUE', 'done=', 'done', 'done=true&done=false']) {
assert.deepEqual(await call('GET', `/tasks?${query}`), refused, query);
}
});
});

it('refuses a task without a title, and a body that is not JSON', async () => {
assert.deepEqual(await call('POST', '/tasks', { title: '' }), { status: 400, json: { error: 'title must be text' } });
assert.deepEqual(await call('POST', '/tasks', 'not json'), { status: 400, json: { error: 'the body must be JSON' } });
Expand Down
19 changes: 19 additions & 0 deletions test/tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,25 @@ describe('the task list', () => {
assert.equal(tasks.get(id)?.title, 'Write the spec');
});

it('lists only the tasks that are done, or only those that are not, in order', () => {
const tasks = new TaskList();
assert.deepEqual(tasks.all(true), []);
assert.deepEqual(tasks.all(false), []);

const first = tasks.add('Write the spec');
const second = tasks.add('Review it');
const third = tasks.add('Ship it');
tasks.complete(first.id);
tasks.complete(third.id);

assert.deepEqual(tasks.all(true), [
{ id: first.id, title: 'Write the spec', done: true },
{ id: third.id, title: 'Ship it', done: true },
]);
assert.deepEqual(tasks.all(false), [{ id: second.id, title: 'Review it', done: false }]);
assert.deepEqual(tasks.all().map((task) => task.id), [first.id, second.id, third.id]);
});

it('removes a task once', () => {
const tasks = new TaskList();
const { id } = tasks.add('Clean up');
Expand Down
Loading