Skip to content

Commit 41a51d8

Browse files
authored
Merge pull request dubinc#831 from dubinc/link-api-response-fixes
Validate the response for /links endpoints
2 parents ff6c454 + 7aa1e9f commit 41a51d8

13 files changed

Lines changed: 138 additions & 43 deletions

File tree

apps/web/app/api/links/[linkId]/route.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { DubApiError, ErrorCodes } from "@/lib/api/errors";
2-
import { deleteLink, editLink, processLink } from "@/lib/api/links";
2+
import {
3+
deleteLink,
4+
editLink,
5+
processLink,
6+
transformLink,
7+
} from "@/lib/api/links";
38
import { withWorkspace } from "@/lib/auth";
49
import prisma from "@/lib/prisma";
510
import { NewLinkProps } from "@/lib/types";
6-
import { updateLinkBodySchema } from "@/lib/zod/schemas";
11+
import { LinkSchemaExtended, updateLinkBodySchema } from "@/lib/zod/schemas";
712
import { NextResponse } from "next/server";
813

914
// GET /api/links/[linkId] – get a link
@@ -29,16 +34,17 @@ export const GET = withWorkspace(async ({ headers, link }) => {
2934
color: true,
3035
},
3136
});
32-
return NextResponse.json(
33-
{
34-
...link,
35-
tagId: tags?.[0]?.id ?? null, // backwards compatibility
36-
tags,
37-
},
38-
{
39-
headers,
40-
},
41-
);
37+
38+
const response = transformLink({
39+
...link,
40+
tags: tags.map((tag) => {
41+
return { tag };
42+
}),
43+
});
44+
45+
return NextResponse.json(LinkSchemaExtended.parse(response), {
46+
headers,
47+
});
4248
});
4349

4450
// PUT /api/links/[linkId] – update a link
@@ -97,7 +103,7 @@ export const PUT = withWorkspace(async ({ req, headers, workspace, link }) => {
97103
updatedLink: processedLink,
98104
});
99105

100-
return NextResponse.json(response, {
106+
return NextResponse.json(LinkSchemaExtended.parse(response), {
101107
headers,
102108
});
103109
});

apps/web/app/api/links/bulk/route.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { bulkCreateLinks, combineTagIds, processLink } from "@/lib/api/links";
33
import { withWorkspace } from "@/lib/auth";
44
import prisma from "@/lib/prisma";
55
import { ProcessedLinkProps } from "@/lib/types";
6-
import { bulkCreateLinksBodySchema } from "@/lib/zod/schemas";
6+
import {
7+
LinkSchemaExtended,
8+
bulkCreateLinksBodySchema,
9+
} from "@/lib/zod/schemas";
710
import { NextResponse } from "next/server";
811

912
// POST /api/links/bulk – bulk create up to 100 links
@@ -118,9 +121,12 @@ export const POST = withWorkspace(
118121
const validLinksResponse =
119122
validLinks.length > 0 ? await bulkCreateLinks({ links: validLinks }) : [];
120123

121-
return NextResponse.json([...validLinksResponse, ...errorLinks], {
122-
headers,
123-
});
124+
return NextResponse.json(
125+
[...LinkSchemaExtended.array().parse(validLinksResponse), ...errorLinks],
126+
{
127+
headers,
128+
},
129+
);
124130
},
125131
{
126132
needNotExceededLinks: true,

apps/web/app/api/links/info/route.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { DubApiError } from "@/lib/api/errors";
22
import { LinkWithTags, transformLink } from "@/lib/api/links";
33
import { withWorkspace } from "@/lib/auth";
44
import prisma from "@/lib/prisma";
5-
import { getLinkInfoQuerySchema } from "@/lib/zod/schemas";
5+
import { LinkSchemaExtended, getLinkInfoQuerySchema } from "@/lib/zod/schemas";
66
import { NextResponse } from "next/server";
77

88
// GET /api/links/info – get the info for a link
@@ -52,7 +52,10 @@ export const GET = withWorkspace(async ({ headers, searchParams, link }) => {
5252
? { ...link, ...tagsAndUser }
5353
: { ...link, tags: [] };
5454

55-
return NextResponse.json(transformLink(linkWithTags), {
56-
headers,
57-
});
55+
return NextResponse.json(
56+
LinkSchemaExtended.parse(transformLink(linkWithTags)),
57+
{
58+
headers,
59+
},
60+
);
5861
});

apps/web/app/api/links/route.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,19 @@ import { createLink, getLinksForWorkspace, processLink } from "@/lib/api/links";
33
import { parseRequestBody } from "@/lib/api/utils";
44
import { withWorkspace } from "@/lib/auth";
55
import { ratelimit } from "@/lib/upstash";
6-
import { createLinkBodySchema, getLinksQuerySchema } from "@/lib/zod/schemas";
6+
import {
7+
LinkSchemaExtended,
8+
createLinkBodySchema,
9+
getLinksQuerySchemaExtended,
10+
} from "@/lib/zod/schemas";
11+
import { UserSchema } from "@/lib/zod/schemas/users";
712
import { LOCALHOST_IP, getSearchParamsWithArray } from "@dub/utils";
813
import { NextResponse } from "next/server";
914

15+
const LinkSchemaWithUser = LinkSchemaExtended.extend({
16+
user: UserSchema.nullable(),
17+
});
18+
1019
// GET /api/links – get all links for a workspace
1120
export const GET = withWorkspace(async ({ req, headers, workspace }) => {
1221
const searchParams = getSearchParamsWithArray(req.url);
@@ -21,7 +30,8 @@ export const GET = withWorkspace(async ({ req, headers, workspace }) => {
2130
userId,
2231
showArchived,
2332
withTags,
24-
} = getLinksQuerySchema.parse(searchParams);
33+
includeUser,
34+
} = getLinksQuerySchemaExtended.parse(searchParams);
2535

2636
const response = await getLinksForWorkspace({
2737
workspaceId: workspace.id,
@@ -34,9 +44,14 @@ export const GET = withWorkspace(async ({ req, headers, workspace }) => {
3444
userId,
3545
showArchived,
3646
withTags,
47+
includeUser,
3748
});
3849

39-
return NextResponse.json(response, {
50+
const links = (includeUser ? LinkSchemaWithUser : LinkSchemaExtended)
51+
.array()
52+
.parse(response);
53+
54+
return NextResponse.json(links, {
4055
headers,
4156
});
4257
});
@@ -75,7 +90,7 @@ export const POST = withWorkspace(
7590

7691
const response = await createLink(link);
7792

78-
return NextResponse.json(response, { headers });
93+
return NextResponse.json(LinkSchemaExtended.parse(response), { headers });
7994
},
8095
{
8196
needNotExceededLinks: true,

apps/web/lib/api/links/get-links-for-workspace.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import prisma from "@/lib/prisma";
22
import z from "@/lib/zod";
3-
import { getLinksQuerySchema } from "@/lib/zod/schemas";
3+
import { getLinksQuerySchemaExtended } from "@/lib/zod/schemas";
44
import { combineTagIds, transformLink } from "./utils";
55

66
export async function getLinksForWorkspace({
@@ -15,7 +15,8 @@ export async function getLinksForWorkspace({
1515
userId,
1616
showArchived,
1717
withTags,
18-
}: z.infer<typeof getLinksQuerySchema> & {
18+
includeUser,
19+
}: z.infer<typeof getLinksQuerySchemaExtended> & {
1920
workspaceId: string;
2021
}) {
2122
const combinedTagIds = combineTagIds({ tagId, tagIds });
@@ -60,7 +61,7 @@ export async function getLinksForWorkspace({
6061
...(userId && { userId }),
6162
},
6263
include: {
63-
user: true,
64+
user: includeUser,
6465
tags: {
6566
include: {
6667
tag: {

apps/web/lib/swr/use-links.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export default function useLinks() {
2323
>(
2424
id
2525
? `/api/links${getQueryString(
26-
{ workspaceId: id },
26+
{ workspaceId: id, includeUser: "true" },
2727
{
2828
ignore: ["import", "upgrade", "newLink"],
2929
},

apps/web/lib/zod/schemas/links.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,6 @@ export const LinkSchema = z
272272
.describe("Whether the short link is archived."),
273273
expiresAt: z
274274
.string()
275-
.datetime()
276275
.nullable()
277276
.describe(
278277
"The date and time when the short link will expire in ISO-8601 format.",
@@ -424,3 +423,20 @@ export const getLinkInfoQuerySchema = domainKeySchema.partial().merge(
424423
.openapi({ example: "ext_123456" }),
425424
}),
426425
);
426+
427+
// Used in API routes to parse the response before sending it back to the client
428+
// This is because Prisma returns a `Date` object
429+
// TODO: Find a better way to handle this
430+
export const LinkSchemaExtended = LinkSchema.extend({
431+
createdAt: z.date(),
432+
updatedAt: z.date(),
433+
expiresAt: z.date().nullable(),
434+
lastClicked: z.date().nullable(),
435+
});
436+
437+
export const getLinksQuerySchemaExtended = getLinksQuerySchema.merge(
438+
z.object({
439+
// Only Dub UI uses includeUser query parameter
440+
includeUser: booleanQuerySchema.default("false"),
441+
}),
442+
);

apps/web/lib/zod/schemas/users.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import z from "@/lib/zod";
2+
3+
export const UserSchema = z.object({
4+
id: z.string(),
5+
name: z.string(),
6+
email: z.string(),
7+
emailVerified: z.boolean().nullable(),
8+
image: z.string().nullable(),
9+
subscribed: z.boolean().nullable(),
10+
source: z.string().nullable(),
11+
});
Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,57 @@
1+
import z from "@/lib/zod";
2+
import { LinkSchema } from "@/lib/zod/schemas";
13
import { Link } from "@prisma/client";
24
import { expect, test } from "vitest";
35
import { randomId } from "../utils/helpers";
46
import { IntegrationHarness } from "../utils/integration";
7+
import { link } from "../utils/resource";
8+
import { expectedLink } from "../utils/schema";
9+
10+
const { domain } = link;
511

612
test("POST /links/bulk", async (ctx) => {
713
const h = new IntegrationHarness(ctx);
8-
const { workspace, http } = await h.init();
14+
const { workspace, http, user } = await h.init();
915
const { workspaceId } = workspace;
16+
const projectId = workspaceId.replace("ws_", "");
1017

1118
const bulkLinks = Array.from({ length: 2 }, () => ({
1219
url: `https://example.com/${randomId()}`,
20+
domain,
1321
}));
1422

15-
const { status, data: links } = await http.post<Link>({
23+
const { status, data: links } = await http.post<Link[]>({
1624
path: "/links/bulk",
1725
query: { workspaceId },
1826
body: bulkLinks,
1927
});
2028

29+
const firstLink = links.find((l) => l.url === bulkLinks[0].url);
30+
const secondLink = links.find((l) => l.url === bulkLinks[1].url);
31+
2132
expect(status).toEqual(200);
2233
expect(links).toHaveLength(2);
34+
expect(firstLink).toStrictEqual({
35+
...expectedLink,
36+
url: bulkLinks[0].url,
37+
userId: user.id,
38+
projectId,
39+
workspaceId,
40+
shortLink: `https://${domain}/${firstLink?.key}`,
41+
qrCode: `https://api.dub.co/qr?url=https://${domain}/${firstLink?.key}?qr=1`,
42+
tags: [],
43+
});
44+
expect(secondLink).toStrictEqual({
45+
...expectedLink,
46+
url: bulkLinks[1].url,
47+
userId: user.id,
48+
projectId,
49+
workspaceId,
50+
shortLink: `https://${domain}/${secondLink?.key}`,
51+
qrCode: `https://api.dub.co/qr?url=https://${domain}/${secondLink?.key}?qr=1`,
52+
tags: [],
53+
});
54+
expect(z.array(LinkSchema.strict()).parse(links)).toBeTruthy();
2355

2456
await Promise.all([h.deleteLink(links[0].id), h.deleteLink(links[1].id)]);
2557
});

apps/web/tests/links/create-link.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { LinkSchema } from "@/lib/zod/schemas";
12
import { Link, Tag } from "@prisma/client";
23
import { describe, expect, test } from "vitest";
34
import { randomId } from "../utils/helpers";
@@ -44,6 +45,7 @@ describe.sequential("POST /links", async () => {
4445
qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,
4546
tags: [],
4647
});
48+
expect(LinkSchema.strict().parse(link)).toBeTruthy();
4749

4850
await h.deleteLink(link.id);
4951
});
@@ -73,6 +75,7 @@ describe.sequential("POST /links", async () => {
7375
qrCode: `https://api.dub.co/qr?url=https://${domain}/${key}?qr=1`,
7476
tags: [],
7577
});
78+
expect(LinkSchema.strict().parse(link)).toBeTruthy();
7679

7780
await h.deleteLink(link.id);
7881
});
@@ -105,6 +108,7 @@ describe.sequential("POST /links", async () => {
105108
qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,
106109
tags: [],
107110
});
111+
expect(LinkSchema.strict().parse(link)).toBeTruthy();
108112

109113
await h.deleteLink(link.id);
110114
});
@@ -144,6 +148,7 @@ describe.sequential("POST /links", async () => {
144148
qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,
145149
tags: [],
146150
});
151+
expect(LinkSchema.strict().parse(link)).toBeTruthy();
147152

148153
await h.deleteLink(link.id);
149154
});
@@ -173,6 +178,7 @@ describe.sequential("POST /links", async () => {
173178
qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,
174179
tags: [],
175180
});
181+
expect(LinkSchema.strict().parse(link)).toBeTruthy();
176182

177183
await h.deleteLink(link.id);
178184
});
@@ -205,6 +211,7 @@ describe.sequential("POST /links", async () => {
205211
qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,
206212
tags: [],
207213
});
214+
expect(LinkSchema.strict().parse(link)).toBeTruthy();
208215

209216
await h.deleteLink(link.id);
210217
});
@@ -238,6 +245,7 @@ describe.sequential("POST /links", async () => {
238245
qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,
239246
tags: [],
240247
});
248+
expect(LinkSchema.strict().parse(link)).toBeTruthy();
241249

242250
await h.deleteLink(link.id);
243251
});
@@ -271,6 +279,7 @@ describe.sequential("POST /links", async () => {
271279
qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,
272280
tags: [],
273281
});
282+
expect(LinkSchema.strict().parse(link)).toBeTruthy();
274283

275284
await h.deleteLink(link.id);
276285
});
@@ -323,6 +332,7 @@ describe.sequential("POST /links", async () => {
323332
qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,
324333
tags: expect.arrayContaining(tags),
325334
});
335+
expect(LinkSchema.strict().parse(link)).toBeTruthy();
326336

327337
await Promise.all([
328338
...tagIds.map((id) => h.deleteTag(id)),
@@ -358,6 +368,7 @@ describe.sequential("POST /links", async () => {
358368
qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,
359369
tags: [],
360370
});
371+
expect(LinkSchema.strict().parse(link)).toBeTruthy();
361372

362373
await h.deleteLink(link.id);
363374
});

0 commit comments

Comments
 (0)