Skip to content

Commit

Permalink
Connect pano to GQL (#572)
Browse files Browse the repository at this point in the history
This PR connects pano to GQL.

Closes #544 
Closes #545 
Closes #582 
Closes #588 
Closes #589 
Closes #590
  • Loading branch information
usirin authored Aug 4, 2023
1 parent 62733d1 commit ecc6c6d
Show file tree
Hide file tree
Showing 31 changed files with 630 additions and 72 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ env:
TWITCH_SECRET: "fake"
DISCORD_ID: "fake"
DISCORD_SECRET: "fake"
NEXT_PUBLIC_GQL_URL: "https://gql.dev.kamp.us/graphql"

jobs:
build:
Expand Down
1 change: 1 addition & 0 deletions apps/gql/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ NODE_ENV=development
PORT=4000
COMPOSE_POSTGRES_PORT=5555
DATABASE_URL=mysql://kampus:kampus@localhost:3306/kampus?schema=public
NEXTAUTH_URL=http://localhost:3002/auth
10 changes: 10 additions & 0 deletions apps/gql/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { type Clients } from "./clients";
import { createPanoActions } from "./features/pano/post";

export type DataActions = ReturnType<typeof createActions>;

export function createActions(clients: Clients) {
return {
pano: createPanoActions(clients),
};
}
27 changes: 22 additions & 5 deletions apps/gql/app/graphql/route.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,38 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { type NextApiRequest, type NextApiResponse } from "next";
import { createSchema, createYoga } from "graphql-yoga";

import { getServerSession } from "@kampus/next-auth";

import { createActions } from "~/actions";
import { createClients } from "~/clients";
import { createLoaders } from "~/loaders";
import { resolvers } from "~/schema/resolvers";
import { type KampusGQLContext } from "~/schema/types";

const typeDefs = readFileSync(join(process.cwd(), "schema/schema.graphql"), "utf8").toString();
const clients = createClients();

const { handleRequest } = createYoga({
schema: createSchema({ typeDefs, resolvers }),
type ServerContext = { req: NextApiRequest; res: NextApiResponse } & KampusGQLContext;

const { handleRequest } = createYoga<ServerContext>({
schema: createSchema<ServerContext>({ typeDefs, resolvers }),
logging: "debug",
graphiql: true,
context: () => ({
loaders: createLoaders(clients),
}),
context: async ({ req, res }) => {
const loaders = createLoaders(clients);
const actions = createActions(clients);
const session = await getServerSession(req, res);

return {
loaders,
actions,
pasaport: {
session,
},
} satisfies KampusGQLContext;
},

fetchAPI: {
Response,
Expand Down
2 changes: 2 additions & 0 deletions apps/gql/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ export const env = parseEnv(
{
NODE_ENV: process.env.NODE_ENV,
DATABASE_URL: process.env.DATABASE_URL,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
},
{
NODE_ENV: z.enum(["development", "test", "production"]),
DATABASE_URL: z
.string()
.url()
.default("mysql://kampus:kampus@localhost:3306/kampus?schema=public&connect_timeout=300"),
NEXTAUTH_URL: z.string().url().default("http://localhost:3002/auth"),
}
);
6 changes: 6 additions & 0 deletions apps/gql/features/errors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const makeErrorFactory =
<T extends string>(__typename: T, defaultValue: string) =>
(message = defaultValue) => ({ __typename, message });

export const NotAuthorized = makeErrorFactory("NotAuthorized", "Not authorized");
export const InvalidInput = makeErrorFactory("InvalidInput", "Invalid input");
57 changes: 57 additions & 0 deletions apps/gql/features/pano/post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { type Clients } from "~/clients";
import { getSitename } from "./utils/get-sitename";

export function createPanoActions(clients: Clients) {
return {
post: createPanoPostActions(clients),
};
}

interface CreatePanoPostArgs {
title: string;
userID: string;
url: string | null;
content: string | null;
}

interface UpdatePanoPostArgs {
title: string;
url: string | null;
content: string | null;
}

function createPanoPostActions({ prisma }: Clients) {
const create = (args: CreatePanoPostArgs) => {
return prisma.post.create({
data: {
title: args.title,
url: args.url,
site: getSitename(args.url),
content: args.content,
owner: { connect: { id: args.userID } },
},
});
};

const update = (id: string, args: UpdatePanoPostArgs) => {
return prisma.post.update({
where: { id },
data: {
title: args.title,
url: args.url,
site: getSitename(args.url),
content: args.content,
},
});
};

const remove = (id: string) => {
return prisma.post.delete({ where: { id } });
};

return {
create,
update,
remove,
};
}
21 changes: 21 additions & 0 deletions apps/gql/features/pano/utils/get-sitename.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";

import { getSitename } from "./get-sitename";

describe(getSitename, () => {
it("returns undefined when href is null", () => {
expect(getSitename(null)).toBeUndefined();
});

it("returns hostname of when href is a correct url", () => {
expect(getSitename("https://kamp.us/foo/bar/baz")).toBe("kamp.us");
});

it.each(["twitch.tv", "twitter.com", "github.com"])(
"uses overrides withUsername override for %j",
(override) => {
const href = `https://${override}/username/foo/bar`;
expect(getSitename(href)).toBe(`${override}/username`);
}
);
});
25 changes: 25 additions & 0 deletions apps/gql/features/pano/utils/get-sitename.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
interface Override {
execute: (url: URL) => string;
}

const withUsername = {
execute(url) {
const [, username] = url.pathname.split("/");
return `${url.hostname}/${username}`;
},
} satisfies Override;

const overrides: Record<string, Override> = {
"twitch.tv": withUsername,
"twitter.com": withUsername,
"github.com": withUsername,
};

export const getSitename = (href: string | null) => {
if (!href) {
return;
}

const url = new URL(href);
return overrides[url.hostname]?.execute(url) ?? url.hostname;
};
8 changes: 8 additions & 0 deletions apps/gql/loaders/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,15 @@ export function createUserLoaders(clients: Clients) {
});
});

const byEmail = createPrismaLoader(clients.prisma.user, "email", (users) => {
users.forEach((user) => {
byUsername.prime(user.id, user);
});
});

return {
byID,
byEmail,
byUsername,
};
}
Expand All @@ -31,5 +38,6 @@ export const transformUser = (user: User) => ({
...user,
__typename: "User" as const,
username: user.username ?? "",
displayName: user.name ?? user.username ?? "",
panoPosts: null,
});
1 change: 1 addition & 0 deletions apps/gql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"dependencies": {
"@graphql-tools/schema": "9.0.19",
"@kampus/gql-utils": "*",
"@kampus/next-auth": "*",
"@kampus/prisma": "*",
"@kampus/sozluk-content": "*",
"@kampus/std": "*",
Expand Down
95 changes: 87 additions & 8 deletions apps/gql/schema/resolvers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { type User } from "@kampus/prisma";
import { assertNever } from "@kampus/std";
import { type Dictionary } from "@kampus/std/dictionary";

import { InvalidInput, NotAuthorized } from "~/features/errors";
import {
transformPanoComment,
transformPanoCommentConnection,
Expand All @@ -31,12 +32,24 @@ const parseConnectionArgs = (args: ConnectionArguments) => ({
before: args.before ? parse(args.before).value : null,
});

const errorFieldsResolver = {
message: (error: { message: string }) => error.message,
};

export const resolvers = {
Date: DateResolver,
DateTime: DateTimeResolver,
Node: {},
Actor: {},

Query: {
// @see Viewer field resolvers
viewer: () => ({ actor: null }),
// @see SozlukQuery field resolvers
sozluk: () => ({ term: null, terms: null }),
// @see PanoQuery field resolvers
pano: () => ({ post: null, posts: [], allPosts: null }),

node: async (_, args, { loaders }) => {
const id = parse<NodeTypename>(args.id);

Expand All @@ -53,7 +66,6 @@ export const resolvers = {
return assertNever(id.type);
}
},

user: async (_, args, { loaders }) => {
let user: User | null = null;

Expand All @@ -69,16 +81,22 @@ export const resolvers = {

return transformUser(user);
},
},
Viewer: {
actor: async (_viewer, _args, { loaders, pasaport: { session } }) => {
if (!session?.user?.id) {
return null;
}

sozluk: () => {
return {
term: null,
terms: null,
};
},
const user = await loaders.user.byID.load(session.user.id);
if (!user) {
return null;
}

pano: () => ({ post: null, posts: [], allPosts: null }),
return transformUser(user);
},
},

SozlukQuery: {
term: async (_, args, { loaders }) =>
transformSozlukTerm(await loaders.sozluk.term.load(args.id)),
Expand Down Expand Up @@ -131,6 +149,7 @@ export const resolvers = {
title: (post) => post.title,
url: (post) => post.url,
content: (post) => post.content,
site: (post) => post.site,
owner: async (parent, _, { loaders }) => {
const post = await loaders.pano.post.byID.load(parent.id);
const user = await loaders.user.byID.load(post.userID);
Expand Down Expand Up @@ -165,6 +184,7 @@ export const resolvers = {
User: {
id: (user) => stringify("User", user.id),
username: (u) => u.username,
displayName: (u) => u.displayName,
panoPosts: async (user, args, { loaders }) =>
transformPanoPostConnection(
await loaders.pano.post.byUserID.load(new ConnectionKey(user.id, args))
Expand Down Expand Up @@ -211,4 +231,63 @@ export const resolvers = {
pageInfo: (connection) => transformPageInfo("PanoComment", connection.pageInfo),
totalCount: (connection) => connection.totalCount,
},

CreatePanoPostPayload: {}, // union
UpdatePanoPostPayload: {}, // union
RemovePanoPostPayload: {}, // union
UserError: {}, // interface

InvalidInput: errorFieldsResolver,
NotAuthorized: errorFieldsResolver,

Mutation: {
createPanoPost: async (_, { input }, { loaders, actions, pasaport: { session } }) => {
if (!session?.user?.id) {
return NotAuthorized();
}

if (!input.url && !input.content) {
return InvalidInput("Either url or content is required");
}

const created = await actions.pano.post.create({ ...input, userID: session.user.id });
return transformPanoPost(await loaders.pano.post.byID.load(created.id));
},

updatePanoPost: async (_, { input }, { actions, loaders, pasaport: { session } }) => {
if (!session?.user?.id) {
return NotAuthorized();
}

const id = parse(input.id);
if (id.type !== "PanoPost") {
return InvalidInput("wrong id");
}

const post = await loaders.pano.post.byID.load(id.value);
if (post.userID !== session.user.id) {
return NotAuthorized();
}

const updated = await actions.pano.post.update(id.value, input);
return transformPanoPost(await loaders.pano.post.byID.clear(updated.id).load(updated.id));
},
removePanoPost: async (_, { input }, { actions, loaders, pasaport: { session } }) => {
if (!session?.user?.id) {
return NotAuthorized();
}

const id = parse(input.id);
if (id.type !== "PanoPost") {
return InvalidInput("wrong id");
}

const post = await loaders.pano.post.byID.load(id.value);
if (post.userID !== session.user.id) {
return NotAuthorized();
}

return transformPanoPost(await actions.pano.post.remove(id.value));
},
},
} satisfies Resolvers;
Loading

0 comments on commit ecc6c6d

Please sign in to comment.