Skip to content

Commit

Permalink
feat: REST API for items and comments (denoland#441)
Browse files Browse the repository at this point in the history
This change adds the following REST API endpoints:
- `GET /api/items`
- `GET /api/items/[id]`
- `GET /api/items/[id]/comments`

Documentation will come in a follow-up PR.

Prerequisite for denoland#438 or denoland#429
Towards denoland#439
  • Loading branch information
iuioiua authored Aug 21, 2023
1 parent 70f34eb commit 1fca62c
Show file tree
Hide file tree
Showing 8 changed files with 201 additions and 73 deletions.
75 changes: 67 additions & 8 deletions e2e_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,30 @@ import {
assertEquals,
assertFalse,
assertInstanceOf,
assertNotEquals,
assertStringIncludes,
} from "std/testing/asserts.ts";
import { genNewItem, genNewUser } from "@/utils/db_test.ts";
import { createItem, createUser, type Item } from "@/utils/db.ts";
import { genNewComment, genNewItem, genNewUser } from "@/utils/db_test.ts";
import {
type Comment,
createComment,
createItem,
createUser,
type Item,
} from "@/utils/db.ts";

function assertResponseNotFound(resp: Response) {
assertFalse(resp.ok);
assertEquals(resp.body, null);
assertEquals(resp.status, Status.NotFound);
}

function assertResponseJson(resp: Response) {
assert(resp.ok);
assertNotEquals(resp.body, null);
assertEquals(resp.headers.get("content-type"), "application/json");
}

Deno.test("[http]", async (test) => {
const handler = await createHandler(manifest);

Expand Down Expand Up @@ -167,6 +180,55 @@ Deno.test("[http]", async (test) => {
assertEquals(resp.status, 200);
});

await test.step("GET /api/items", async () => {
const item1 = genNewItem();
const item2 = genNewItem();
await createItem(item1);
await createItem(item2);

const req = new Request("http://localhost/api/items");
const resp = await handler(req);

const { items } = await resp.json();
assertResponseJson(resp);
assertArrayIncludes(items, [
JSON.parse(JSON.stringify(item1)),
JSON.parse(JSON.stringify(item2)),
]);
});

await test.step("GET /api/items/[id]", async () => {
const item = genNewItem();
const req = new Request("http://localhost/api/items/" + item.id);

const resp1 = await handler(req);
assertResponseNotFound(resp1);

await createItem(item);
const resp2 = await handler(req);
assertResponseJson(resp2);
assertEquals(await resp2.json(), JSON.parse(JSON.stringify(item)));
});

await test.step("GET /api/items/[id]/comments", async () => {
const item = genNewItem();
const comment: Comment = {
...genNewComment(),
itemId: item.id,
};
const req = new Request(`http://localhost/api/items/${item.id}/comments`);

const resp1 = await handler(req);
assertResponseNotFound(resp1);

await createItem(item);
await createComment(comment);
const resp2 = await handler(req);
const { comments } = await resp2.json();
assertResponseJson(resp2);
assertEquals(comments, JSON.parse(JSON.stringify(comments)));
});

await test.step("GET /api/users", async () => {
const user1 = genNewUser();
const user2 = genNewUser();
Expand All @@ -177,8 +239,7 @@ Deno.test("[http]", async (test) => {
const resp = await handler(req);

const { users } = await resp.json();
assert(resp.ok);
assertEquals(resp.headers.get("content-type"), "application/json");
assertResponseJson(resp);
assertArrayIncludes(users, [user1, user2]);
});

Expand All @@ -191,8 +252,7 @@ Deno.test("[http]", async (test) => {

await createUser(user);
const resp2 = await handler(req);
assert(resp2.ok);
assertEquals(resp2.headers.get("content-type"), "application/json");
assertResponseJson(resp2);
assertEquals(await resp2.json(), user);
});

Expand All @@ -212,8 +272,7 @@ Deno.test("[http]", async (test) => {

const resp2 = await handler(req);
const { items } = await resp2.json();
assert(resp2.ok);
assertEquals(resp2.headers.get("content-type"), "application/json");
assertResponseJson(resp2);
assertArrayIncludes(items, [JSON.parse(JSON.stringify(item))]);
});
});
102 changes: 54 additions & 48 deletions fresh.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,33 @@ import * as $4 from "./routes/account/_middleware.ts";
import * as $5 from "./routes/account/index.tsx";
import * as $6 from "./routes/account/manage.ts";
import * as $7 from "./routes/account/upgrade.ts";
import * as $8 from "./routes/api/stripe-webhooks.ts";
import * as $9 from "./routes/api/users/[login]/index.ts";
import * as $10 from "./routes/api/users/[login]/items.ts";
import * as $11 from "./routes/api/users/index.ts";
import * as $12 from "./routes/api/vote.ts";
import * as $13 from "./routes/blog/[slug].tsx";
import * as $14 from "./routes/blog/index.tsx";
import * as $15 from "./routes/callback.ts";
import * as $16 from "./routes/dashboard/_middleware.ts";
import * as $17 from "./routes/dashboard/index.tsx";
import * as $18 from "./routes/dashboard/stats.tsx";
import * as $19 from "./routes/dashboard/users.tsx";
import * as $20 from "./routes/feed.ts";
import * as $21 from "./routes/index.tsx";
import * as $22 from "./routes/items/[id].tsx";
import * as $23 from "./routes/notifications/[id].ts";
import * as $24 from "./routes/notifications/_middleware.ts";
import * as $25 from "./routes/notifications/index.tsx";
import * as $26 from "./routes/pricing.tsx";
import * as $27 from "./routes/signin.ts";
import * as $28 from "./routes/signout.ts";
import * as $29 from "./routes/submit/_middleware.tsx";
import * as $30 from "./routes/submit/index.tsx";
import * as $31 from "./routes/users/[login].tsx";
import * as $8 from "./routes/api/items/[id]/comments.ts";
import * as $9 from "./routes/api/items/[id]/index.ts";
import * as $10 from "./routes/api/items/index.ts";
import * as $11 from "./routes/api/stripe-webhooks.ts";
import * as $12 from "./routes/api/users/[login]/index.ts";
import * as $13 from "./routes/api/users/[login]/items.ts";
import * as $14 from "./routes/api/users/index.ts";
import * as $15 from "./routes/api/vote.ts";
import * as $16 from "./routes/blog/[slug].tsx";
import * as $17 from "./routes/blog/index.tsx";
import * as $18 from "./routes/callback.ts";
import * as $19 from "./routes/dashboard/_middleware.ts";
import * as $20 from "./routes/dashboard/index.tsx";
import * as $21 from "./routes/dashboard/stats.tsx";
import * as $22 from "./routes/dashboard/users.tsx";
import * as $23 from "./routes/feed.ts";
import * as $24 from "./routes/index.tsx";
import * as $25 from "./routes/items/[id].tsx";
import * as $26 from "./routes/notifications/[id].ts";
import * as $27 from "./routes/notifications/_middleware.ts";
import * as $28 from "./routes/notifications/index.tsx";
import * as $29 from "./routes/pricing.tsx";
import * as $30 from "./routes/signin.ts";
import * as $31 from "./routes/signout.ts";
import * as $32 from "./routes/submit/_middleware.tsx";
import * as $33 from "./routes/submit/index.tsx";
import * as $34 from "./routes/users/[login].tsx";
import * as $$0 from "./islands/Chart.tsx";
import * as $$1 from "./islands/PageInput.tsx";
import * as $$2 from "./islands/VoteButton.tsx";
Expand All @@ -48,30 +51,33 @@ const manifest = {
"./routes/account/index.tsx": $5,
"./routes/account/manage.ts": $6,
"./routes/account/upgrade.ts": $7,
"./routes/api/stripe-webhooks.ts": $8,
"./routes/api/users/[login]/index.ts": $9,
"./routes/api/users/[login]/items.ts": $10,
"./routes/api/users/index.ts": $11,
"./routes/api/vote.ts": $12,
"./routes/blog/[slug].tsx": $13,
"./routes/blog/index.tsx": $14,
"./routes/callback.ts": $15,
"./routes/dashboard/_middleware.ts": $16,
"./routes/dashboard/index.tsx": $17,
"./routes/dashboard/stats.tsx": $18,
"./routes/dashboard/users.tsx": $19,
"./routes/feed.ts": $20,
"./routes/index.tsx": $21,
"./routes/items/[id].tsx": $22,
"./routes/notifications/[id].ts": $23,
"./routes/notifications/_middleware.ts": $24,
"./routes/notifications/index.tsx": $25,
"./routes/pricing.tsx": $26,
"./routes/signin.ts": $27,
"./routes/signout.ts": $28,
"./routes/submit/_middleware.tsx": $29,
"./routes/submit/index.tsx": $30,
"./routes/users/[login].tsx": $31,
"./routes/api/items/[id]/comments.ts": $8,
"./routes/api/items/[id]/index.ts": $9,
"./routes/api/items/index.ts": $10,
"./routes/api/stripe-webhooks.ts": $11,
"./routes/api/users/[login]/index.ts": $12,
"./routes/api/users/[login]/items.ts": $13,
"./routes/api/users/index.ts": $14,
"./routes/api/vote.ts": $15,
"./routes/blog/[slug].tsx": $16,
"./routes/blog/index.tsx": $17,
"./routes/callback.ts": $18,
"./routes/dashboard/_middleware.ts": $19,
"./routes/dashboard/index.tsx": $20,
"./routes/dashboard/stats.tsx": $21,
"./routes/dashboard/users.tsx": $22,
"./routes/feed.ts": $23,
"./routes/index.tsx": $24,
"./routes/items/[id].tsx": $25,
"./routes/notifications/[id].ts": $26,
"./routes/notifications/_middleware.ts": $27,
"./routes/notifications/index.tsx": $28,
"./routes/pricing.tsx": $29,
"./routes/signin.ts": $30,
"./routes/signout.ts": $31,
"./routes/submit/_middleware.tsx": $32,
"./routes/submit/index.tsx": $33,
"./routes/users/[login].tsx": $34,
},
islands: {
"./islands/Chart.tsx": $$0,
Expand Down
22 changes: 22 additions & 0 deletions routes/api/items/[id]/comments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Handlers, Status } from "$fresh/server.ts";
import { collectValues, getItem, listCommentsByItem } from "@/utils/db.ts";
import { getCursor } from "@/utils/pagination.ts";

// Copyright 2023 the Deno authors. All rights reserved. MIT license.
export const handler: Handlers = {
async GET(req, ctx) {
const itemId = ctx.params.id;
const item = await getItem(itemId);
if (item === null) return new Response(null, { status: Status.NotFound });

const url = new URL(req.url);
const iter = listCommentsByItem(itemId, {
cursor: getCursor(url),
limit: 10,
// Newest to oldest
reverse: true,
});
const comments = await collectValues(iter);
return Response.json({ comments, cursor: iter.cursor });
},
};
12 changes: 12 additions & 0 deletions routes/api/items/[id]/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import { type Handlers, Status } from "$fresh/server.ts";
import { getItem } from "@/utils/db.ts";

export const handler: Handlers = {
async GET(_req, ctx) {
const item = await getItem(ctx.params.id);
return item === null
? new Response(null, { status: Status.NotFound })
: Response.json(item);
},
};
17 changes: 17 additions & 0 deletions routes/api/items/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Handlers } from "$fresh/server.ts";
import { collectValues, listItemsByTime } from "@/utils/db.ts";
import { getCursor } from "@/utils/pagination.ts";

// Copyright 2023 the Deno authors. All rights reserved. MIT license.
export const handler: Handlers = {
async GET(req) {
const url = new URL(req.url);
const iter = listItemsByTime({
cursor: getCursor(url),
limit: 10,
reverse: true,
});
const items = await collectValues(iter);
return Response.json({ items, cursor: iter.cursor });
},
};
19 changes: 5 additions & 14 deletions tools/migrate_kv.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,16 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import { kv, updateUser, User } from "@/utils/db.ts";

type MaybeOldUser = User & {
id?: string;
avatarUrl?: string;
};
import { type Comment, createComment, kv } from "@/utils/db.ts";

export async function migrateKv() {
const promises = [];
const iter = kv.list<MaybeOldUser>({ prefix: ["users"] });
const iter = kv.list<Comment>({ prefix: ["comments_by_item"] });
for await (const entry of iter) {
const user = entry.value;
if (user.id) {
promises.push(kv.delete(["users", user.id]));
delete user.id;
delete user.avatarUrl;
promises.push(updateUser(user));
if (entry.key.length === 3) {
promises.push(kv.delete(entry.key));
promises.push(createComment(entry.value));
}
}
await Promise.all(promises);

console.log("KV migration complete");
}

Expand Down
25 changes: 23 additions & 2 deletions utils/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ export function listItemsByUser(
return kv.list<Item>({ prefix: ["items_by_user", userLogin] }, options);
}

export function listItemsByTime(options?: Deno.KvListOptions) {
return kv.list<Item>({ prefix: ["items_by_time"] }, options);
}

export async function getAllItems() {
return await getValues<Item>({ prefix: ["items"] });
}
Expand Down Expand Up @@ -301,7 +305,12 @@ export function newCommentProps(): Pick<Comment, "id" | "createdAt"> {
}

export async function createComment(comment: Comment) {
const commentsByItemKey = ["comments_by_item", comment.itemId, comment.id];
const commentsByItemKey = [
"comments_by_item",
comment.itemId,
comment.createdAt.getTime(),
comment.id,
];

const res = await kv.atomic()
.check({ key: commentsByItemKey, versionstamp: null })
Expand All @@ -312,7 +321,12 @@ export async function createComment(comment: Comment) {
}

export async function deleteComment(comment: Comment) {
const commentsByItemKey = ["comments_by_item", comment.itemId, comment.id];
const commentsByItemKey = [
"comments_by_item",
comment.itemId,
comment.createdAt.getTime(),
comment.id,
];

const res = await kv.atomic()
.delete(commentsByItemKey)
Expand All @@ -325,6 +339,13 @@ export async function getCommentsByItem(itemId: string) {
return await getValues<Comment>({ prefix: ["comments_by_item", itemId] });
}

export function listCommentsByItem(
itemId: string,
options?: Deno.KvListOptions,
) {
return kv.list<Comment>({ prefix: ["comments_by_item", itemId] }, options);
}

// Vote
export interface Vote {
userLogin: string;
Expand Down
2 changes: 1 addition & 1 deletion utils/db_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import {
} from "std/testing/asserts.ts";
import { DAY } from "std/datetime/constants.ts";

function genNewComment(): Comment {
export function genNewComment(): Comment {
return {
itemId: crypto.randomUUID(),
userLogin: crypto.randomUUID(),
Expand Down

0 comments on commit 1fca62c

Please sign in to comment.