current state implementation
This commit is contained in:
+42
@@ -0,0 +1,42 @@
|
||||
import { ApolloServer } from '@apollo/server'
|
||||
import { resolvers } from './resolvers'
|
||||
import { startStandaloneServer } from '@apollo/server/standalone'
|
||||
import { readFileSync } from 'fs'
|
||||
import { typeDefs as scalarTypeDefs } from 'graphql-scalars'
|
||||
import { knex } from './db'
|
||||
|
||||
const typeDefs = readFileSync('src/schema.graphql', { encoding: 'utf-8' })
|
||||
|
||||
// The ApolloServer constructor requires two parameters: your schema
|
||||
// definition and your set of resolvers.
|
||||
const server = new ApolloServer({
|
||||
typeDefs: [typeDefs, ...scalarTypeDefs],
|
||||
resolvers
|
||||
})
|
||||
|
||||
const startServer = async (): Promise<void> => {
|
||||
const { url } = await startStandaloneServer(server, {
|
||||
listen: { port: 4000 }
|
||||
})
|
||||
|
||||
const plannedMigrations: Array<{ file: string }> = (
|
||||
await knex.migrate.list()
|
||||
)[1]
|
||||
if (plannedMigrations.length > 0) {
|
||||
console.log(
|
||||
`🕰️ planning migrations: ${plannedMigrations
|
||||
.map((m) => m.file)
|
||||
.join(',')}`
|
||||
)
|
||||
}
|
||||
|
||||
await knex.migrate.latest()
|
||||
|
||||
console.log(`🚀 Server ready at: ${url}`)
|
||||
}
|
||||
|
||||
startServer()
|
||||
.then()
|
||||
.catch((err: Error) =>
|
||||
console.log(`🔥 failed to start server ${err.message}`)
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
import 'dotenv/config'
|
||||
import { parseEnv } from 'znv'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const { POSTGRES_URL } = parseEnv(process.env, {
|
||||
POSTGRES_URL: z.string().min(1)
|
||||
})
|
||||
|
||||
console.log([POSTGRES_URL].join(', '))
|
||||
@@ -0,0 +1,4 @@
|
||||
import Knex from 'knex'
|
||||
import config from '../knexfile'
|
||||
|
||||
export const knex = Knex(config)
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Knex } from 'knex'
|
||||
|
||||
const tableName = 'users'
|
||||
|
||||
export async function up (knex: Knex): Promise<void> {
|
||||
return await knex.schema.createTable(tableName, (table) => {
|
||||
table.text('id').primary()
|
||||
table.text('name')
|
||||
})
|
||||
}
|
||||
|
||||
export async function down (knex: Knex): Promise<void> {
|
||||
return await knex.schema.dropTable(tableName)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Knex } from 'knex'
|
||||
|
||||
const tableName = 'resources'
|
||||
|
||||
export async function up (knex: Knex): Promise<void> {
|
||||
return await knex.schema.createTable(tableName, (table) => {
|
||||
table.text('id').primary()
|
||||
table.text('name').notNullable()
|
||||
table
|
||||
.timestamp('createdAt', { precision: 3, useTz: true })
|
||||
.defaultTo(knex.fn.now())
|
||||
})
|
||||
}
|
||||
|
||||
export async function down (knex: Knex): Promise<void> {
|
||||
return await knex.schema.dropTable(tableName)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Knex } from 'knex'
|
||||
|
||||
const tableName = 'comments'
|
||||
|
||||
export async function up (knex: Knex): Promise<void> {
|
||||
return await knex.schema.createTable(tableName, (table) => {
|
||||
table.text('id').primary()
|
||||
table.text('content').notNullable()
|
||||
table
|
||||
.timestamp('createdAt', { precision: 3, useTz: true })
|
||||
.defaultTo(knex.fn.now())
|
||||
|
||||
table.string('userId').references('id').inTable('users')
|
||||
})
|
||||
}
|
||||
|
||||
export async function down (knex: Knex): Promise<void> {
|
||||
return await knex.schema.dropTable(tableName)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Knex } from 'knex'
|
||||
|
||||
const tableName = 'users'
|
||||
|
||||
export async function up (knex: Knex): Promise<void> {
|
||||
return await knex.schema.alterTable(tableName, (table) => {
|
||||
table.text('name').notNullable().alter()
|
||||
})
|
||||
}
|
||||
|
||||
export async function down (knex: Knex): Promise<void> {
|
||||
return await knex.schema.alterTable(tableName, (table) => {
|
||||
table.text('name').nullable().alter()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Knex } from 'knex'
|
||||
|
||||
const tableName = 'resource_acl'
|
||||
|
||||
export async function up (knex: Knex): Promise<void> {
|
||||
return await knex.schema.createTable(tableName, (table) => {
|
||||
table.string('userId').references('id').inTable('users').onDelete('cascade')
|
||||
table
|
||||
.string('resourceId')
|
||||
.references('id')
|
||||
.inTable('resources')
|
||||
.onDelete('cascade')
|
||||
})
|
||||
}
|
||||
|
||||
export async function down (knex: Knex): Promise<void> {
|
||||
return await knex.schema.dropTable(tableName)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Knex } from 'knex'
|
||||
|
||||
const tableName = 'comments'
|
||||
|
||||
export async function up (knex: Knex): Promise<void> {
|
||||
return await knex.schema.alterTable(tableName, (table) => {
|
||||
table.string('resourceId').references('id').inTable('resources')
|
||||
})
|
||||
}
|
||||
|
||||
export async function down (knex: Knex): Promise<void> {
|
||||
return await knex.schema.alterTable(tableName, (table) => {
|
||||
table.dropColumn('resourceId')
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { knex } from "./db";
|
||||
import { UserRecord, Resource, ResourceAcl, Comment } from "./types";
|
||||
|
||||
const Users = () => knex<UserRecord>("users");
|
||||
const Resources = () => knex<Resource>("resources");
|
||||
const ResourceAclRepo = () => knex<ResourceAcl>("resource_acl");
|
||||
const Comments = () => knex<Comment>("comments");
|
||||
|
||||
export const queryUser = async (userId: string): Promise<UserRecord | null> => {
|
||||
return (await Users().where("id", "=", userId).first()) ?? null;
|
||||
};
|
||||
|
||||
export const queryResource = async (
|
||||
resourceId: string,
|
||||
): Promise<Resource | null> => {
|
||||
return (await Resources().where("id", "=", resourceId).first()) ?? null;
|
||||
};
|
||||
|
||||
export const queryResourceAcl = async ({
|
||||
resourceId,
|
||||
userId,
|
||||
}: {
|
||||
resourceId: string;
|
||||
userId: string;
|
||||
}): Promise<ResourceAcl | null> => {
|
||||
return (
|
||||
(await ResourceAclRepo()
|
||||
.where("userId", "=", userId)
|
||||
.andWhere("resourceId", "=", resourceId)
|
||||
.first()) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
export const countResources = async (userId: string): Promise<number> => {
|
||||
const [rawCount] = await ResourceAclRepo().count().where({ userId });
|
||||
return parseInt(rawCount.count as string);
|
||||
};
|
||||
|
||||
export const queryResources = async ({
|
||||
userId,
|
||||
limit,
|
||||
cursor,
|
||||
}: {
|
||||
userId: string;
|
||||
limit: number;
|
||||
cursor: string | null;
|
||||
}) => {
|
||||
const query = Resources()
|
||||
.join("resource_acl", "resources.id", "resource_acl.resourceId")
|
||||
.where({ userId });
|
||||
if (cursor) {
|
||||
query.andWhere("createdAt", "<", cursor);
|
||||
}
|
||||
return await query.limit(limit);
|
||||
};
|
||||
|
||||
export const countComments = async (resourceId: string): Promise<number> => {
|
||||
const [rawCount] = await Comments().count().where({ resourceId });
|
||||
return parseInt(rawCount.count as string);
|
||||
};
|
||||
|
||||
export const queryComments = async ({
|
||||
resourceId,
|
||||
limit,
|
||||
cursor,
|
||||
}: {
|
||||
resourceId: string;
|
||||
limit: number;
|
||||
cursor: string | null;
|
||||
}): Promise<Comment[]> => {
|
||||
const query = Comments().where({ resourceId });
|
||||
if (cursor) {
|
||||
query.andWhere("createdAt", "<", cursor);
|
||||
}
|
||||
return await query.limit(limit);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { queryResourceAcl } from "./repositories";
|
||||
import { getUser, getResource, getComments, getResources } from "./services";
|
||||
import { GraphQLError } from "graphql";
|
||||
import {
|
||||
Resource,
|
||||
ResourceCollection,
|
||||
UserRecord,
|
||||
CommentCollection,
|
||||
PaginationArgs,
|
||||
} from "./types";
|
||||
|
||||
// Resolvers define how to fetch the types defined in your schema.
|
||||
// This resolver retrieves books from the "books" array above.
|
||||
export const resolvers = {
|
||||
Query: {
|
||||
async user(_: unknown, args: { id: string }) {
|
||||
return await getUser(args.id);
|
||||
},
|
||||
async resource(
|
||||
_: unknown,
|
||||
args: { id: string; userId: string },
|
||||
): Promise<Resource> {
|
||||
const maybeAcl = await queryResourceAcl({
|
||||
userId: args.userId,
|
||||
resourceId: args.id,
|
||||
});
|
||||
if (maybeAcl == null) {
|
||||
throw new GraphQLError(
|
||||
"The user doesn't have access to the given resource",
|
||||
{
|
||||
extensions: {
|
||||
code: "FORBIDDEN",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
const maybeResource = await getResource(args.id);
|
||||
if (maybeResource == null) {
|
||||
throw new GraphQLError("Resource not found", {
|
||||
extensions: { code: "RESOURCE_NOT_FOUND" },
|
||||
});
|
||||
}
|
||||
return maybeResource;
|
||||
},
|
||||
},
|
||||
User: {
|
||||
async resources(parent: UserRecord, args: PaginationArgs) {
|
||||
return await getResources({ userId: parent.id, ...args });
|
||||
},
|
||||
},
|
||||
Resource: {
|
||||
async comments(
|
||||
parent: Resource,
|
||||
{ limit, cursor }: PaginationArgs,
|
||||
): Promise<CommentCollection> {
|
||||
return await getComments({
|
||||
resourceId: parent.id,
|
||||
limit,
|
||||
cursor,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
type Comment {
|
||||
id: String!
|
||||
content: String!
|
||||
createdAt: Date!
|
||||
userId: String!
|
||||
}
|
||||
|
||||
type CommentCollection {
|
||||
items: [Comment!]!
|
||||
cursor: String
|
||||
totalCount: Int!
|
||||
}
|
||||
|
||||
type Resource {
|
||||
id: String!
|
||||
name: String!
|
||||
createdAt: DateTime!
|
||||
comments(limit: Int! = 10, cursor: String = null): CommentCollection!
|
||||
}
|
||||
|
||||
type ResourceCollection {
|
||||
items: [Resource!]!
|
||||
cursor: String
|
||||
totalCount: Int!
|
||||
}
|
||||
|
||||
type User {
|
||||
id: String!
|
||||
name: String!
|
||||
resources(limit: Int! = 10, cursor: String = null): ResourceCollection!
|
||||
}
|
||||
|
||||
type Query {
|
||||
user(id: String!): User
|
||||
|
||||
resource(id: String!, userId: String!): Resource
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
queryUser,
|
||||
queryResource,
|
||||
countComments,
|
||||
queryComments,
|
||||
countResources,
|
||||
queryResources,
|
||||
} from "./repositories";
|
||||
import {
|
||||
UserRecord,
|
||||
Resource,
|
||||
CommentCollection,
|
||||
PaginationArgs,
|
||||
ResourceCollection,
|
||||
} from "./types";
|
||||
|
||||
export const getUser = async (id: string): Promise<UserRecord | null> => {
|
||||
return await queryUser(id);
|
||||
};
|
||||
|
||||
export const getResource = async (id: string): Promise<Resource | null> => {
|
||||
return await queryResource(id);
|
||||
};
|
||||
|
||||
interface GetResourcesArgs extends PaginationArgs {
|
||||
userId: string;
|
||||
}
|
||||
export const getResources = async (
|
||||
params: GetResourcesArgs,
|
||||
): Promise<ResourceCollection> => {
|
||||
const totalCount = await countResources(params.userId);
|
||||
const items = await queryResources(params);
|
||||
let cursor = null;
|
||||
if (items.length > 0) {
|
||||
cursor = items.slice(-1)[0].createdAt.toISOString();
|
||||
}
|
||||
return {
|
||||
totalCount,
|
||||
items,
|
||||
cursor,
|
||||
};
|
||||
};
|
||||
|
||||
export const getComments = async (params: {
|
||||
resourceId: string;
|
||||
limit: number;
|
||||
cursor: string | null;
|
||||
}): Promise<CommentCollection> => {
|
||||
// yes, i should be doing base64 de and encoding with the cursor...
|
||||
const totalCount = await countComments(params.resourceId);
|
||||
const items = await queryComments(params);
|
||||
let cursor = null;
|
||||
if (items.length > 0) {
|
||||
cursor = items.slice(-1)[0].createdAt.toISOString();
|
||||
}
|
||||
return {
|
||||
totalCount,
|
||||
items,
|
||||
cursor,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
export interface Comment {
|
||||
id: string;
|
||||
userId: string;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
resourceId: string;
|
||||
}
|
||||
|
||||
export interface PaginationArgs {
|
||||
limit: number;
|
||||
cursor: string | null;
|
||||
}
|
||||
|
||||
interface Collection<T> {
|
||||
totalCount: number;
|
||||
cursor: string | null;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
export interface CommentCollection extends Collection<Comment> {}
|
||||
|
||||
export interface Resource {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface ResourceCollection extends Collection<Resource> {}
|
||||
|
||||
export interface UserRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface User extends UserRecord {
|
||||
resources: {
|
||||
cursor: string | null;
|
||||
totalCount: number;
|
||||
items: Resource[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResourceAcl {
|
||||
userId: string;
|
||||
resourceId: string;
|
||||
}
|
||||
Reference in New Issue
Block a user