Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Connect pano to GQL #572

Merged
merged 22 commits into from
Aug 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
cbada96
chore: fix lint problems
usirin Jul 31, 2023
76da6cc
chore: add next.config.mjs files under app workspaces to tsconfig
usirin Jul 31, 2023
da4a790
chore(packages/relay): fix codestyle
usirin Aug 1, 2023
6bac060
fix(apps/kampus): handle GQL_URL env variable correctly to work with …
usirin Aug 1, 2023
428b0b6
feat(apps/gql): add site resolver to PanoPost type
usirin Aug 1, 2023
f03ffc2
feat(@kampus/next-auth): accept req & res for getServerSession
usirin Aug 1, 2023
bb835d8
fix(apps/kampus): fix pasaport urls
usirin Aug 1, 2023
585863a
feat(apps/kampus): use Pasaport links for auth ops
usirin Aug 1, 2023
499211a
feat(apps/gql): expose pasaport session via gql context
usirin Aug 2, 2023
44691a7
feat(apps/gql): add Query.viewer resolver
usirin Aug 2, 2023
5fe7781
feat(apps/gql): port get-sitename from old pano
usirin Aug 2, 2023
9e3ba6c
feat(apps/gql): add create pano post action
usirin Aug 2, 2023
8272be1
feat(apps/gql): initial commit for UserError abstraction
usirin Aug 2, 2023
d56d909
feat(apps/gql): add createPanoPost mutation
usirin Aug 2, 2023
96a7f4c
fix(apps/kampus): expose DATABASE_URL to kampus/kampus
usirin Aug 2, 2023
4b0ec5a
ci(repo): update turbo.json
usirin Aug 2, 2023
8ca94fb
ci: add env variable to ci gh action
usirin Aug 2, 2023
023af58
feat(apps/gql): add Mutation.updatePanoPost resolver
usirin Aug 3, 2023
ed5fd1f
feat(packages/next-auth): expose session user id
usirin Aug 3, 2023
29b00db
feat(apps/gql): create error factories
usirin Aug 3, 2023
834a6b8
fix(apps/gql): don't use email for session & tidy up resolvers
usirin Aug 3, 2023
71e0ecb
feat(apps/gql): add Mutation.removePanoPost resolver
usirin Aug 4, 2023
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 .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