Skip to content
Draft
69 changes: 69 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,12 @@
"@sentry/node": "8.4.0",
"@sentry/profiling-node": "8.4.0",
"@sentry/utils": "8.4.0",
"dataloader": "^2.2.3",
"dotenv": "^16.4.5",
"get-urls": "12.1.0",
"graphql": "^16.11.0",
"graphql-http": "^1.22.4",
"graphql-relay": "^0.10.2",
"html-entities": "2.5.2",
"ical.js": "^2.0.1",
"is-absolute-url": "4.0.1",
Expand Down Expand Up @@ -74,6 +78,7 @@
"@eslint/js": "^9.3.0",
"@types/eslint__js": "^8.42.3",
"@types/eslint-config-prettier": "^6.11.3",
"@types/graphql": "^14.2.3",
"@types/jsdom": "^21.1.6",
"@types/koa": "^2.15.0",
"@types/koa-bodyparser": "^4.3.12",
Expand All @@ -89,6 +94,7 @@
"ava": "^6.1.3",
"eslint": "^8.0.0",
"eslint-config-prettier": "^9.1.0",
"get-port": "^7.1.0",
"globals": "^15.6.0",
"prettier": "^3.2.5",
"typed-query-selector": "^2.11.2",
Expand Down
220 changes: 220 additions & 0 deletions source/ccc-server/graphql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import {createHandler} from 'graphql-http/lib/use/koa'
import {GraphQLID, GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString} from 'graphql'
import {
type ConnectionArguments,
connectionArgs,
connectionDefinitions,
connectionFromArray,
fromGlobalId,
nodeDefinitions,
toGlobalId,
} from 'graphql-relay'
import {get} from '../ccc-lib/http.js'
import {GH_PAGES} from '../ccci-stolaf-college/v1/gh-pages.js'
import DataLoader from 'dataloader'
import {z, type ZodObject, type ZodRawShape, type ZodTypeAny} from 'zod'
import {URLScalar} from './url-scalar.js'

// #region Type Definitions and Zod Schemas
// =================================================================================

type KnownGraphQLTypeNames = 'Contact' | 'DictionaryDefinition'

const ContactSchema = z.object({
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think we extracted other schemas to their own files but should be okay

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figure we can figure out the desired file structure for these later, but … eh, lemme split them out now.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's fine, we're making gradual changes and can polish later

title: z.string(),
phoneNumber: z.string(),
buttonText: z.string(),
category: z.string(),
image: z.string().optional(),
synopsis: z.string(),
text: z.string(),
})
type Contact = z.infer<typeof ContactSchema>

interface ContactResponse {
data: Contact[]
}

const DictionaryDefinitionSchema = z.object({
word: z.string(),
definition: z.string(),
})

type DictionaryDefinition = z.infer<typeof DictionaryDefinitionSchema>

interface DictionaryDefinitionResponse {
data: DictionaryDefinition[]
}

// #endregion

// #region Data Loaders
// =================================================================================

const contactLoader = new DataLoader<string, Contact[]>(async (keys) => {
const contacts = await Promise.all(keys.map((key) => get(GH_PAGES(key)).json<ContactResponse>()))
return contacts.map((contact) => contact.data)
})

const dictionaryLoader = new DataLoader<string, DictionaryDefinition[]>(async (keys) => {
const dictionary = await Promise.all(
keys.map((key) => get(GH_PAGES(key)).json<DictionaryDefinitionResponse>()),
)
return dictionary.map((entry) => entry.data)
})

// #endregion

// #region Node Interface and Type Registry
// =================================================================================

interface NodeTypeInfo<T extends ZodTypeAny> {
schema: T
fetcher: (id: string) => Promise<z.infer<T> | null>
}

const typeInfoRegistry: {
[key in KnownGraphQLTypeNames]?: NodeTypeInfo<ZodObject<ZodRawShape>>
} = {}

function registerType<T extends ZodObject<ZodRawShape>>(
name: KnownGraphQLTypeNames,
info: NodeTypeInfo<T>,
) {
typeInfoRegistry[name] = info
}

registerType('Contact', {
schema: ContactSchema,
fetcher: async (id: string) => {
const contacts = await contactLoader.load('contact-info.json')
return contacts.find((c) => c.title === id) ?? null
},
})

registerType('DictionaryDefinition', {
schema: DictionaryDefinitionSchema,
fetcher: async (id: string) => {
const definitions = await dictionaryLoader.load('dictionary.json')
return definitions.find((d) => d.word === id) ?? null
},
})

function getObject(globalId: string): Promise<object | null> {
const {type, id} = fromGlobalId(globalId)
const info = typeInfoRegistry[type as KnownGraphQLTypeNames]
if (info) {
return info.fetcher(id)
}
return Promise.resolve(null)
}

function resolveNodeType(obj: object): KnownGraphQLTypeNames | undefined {
for (const name in typeInfoRegistry) {
if (Object.prototype.hasOwnProperty.call(typeInfoRegistry, name)) {
const info = typeInfoRegistry[name as KnownGraphQLTypeNames]
if (info?.schema.safeParse(obj).success) {
return name as KnownGraphQLTypeNames
}
}
}
return undefined
}

const {nodeInterface, nodeField} = nodeDefinitions(getObject, resolveNodeType)

// #endregion

// #region GraphQL Object Types
// =================================================================================

const ContactType = new GraphQLObjectType<Contact, unknown>({
name: 'Contact',
fields: {
id: {
type: new GraphQLNonNull(GraphQLID),
resolve: (contact) => toGlobalId('Contact', contact.title),
},
title: {type: new GraphQLNonNull(GraphQLString)},
phoneNumber: {type: new GraphQLNonNull(GraphQLString)},
buttonText: {type: new GraphQLNonNull(GraphQLString)},
category: {type: new GraphQLNonNull(GraphQLString)},
image: {
type: URLScalar,
resolve: (contact) => {
if (!contact.image) {
return null
}
try {
return new URL(contact.image)
} catch {
return null
}
},
},
synopsis: {type: new GraphQLNonNull(GraphQLString)},
text: {type: new GraphQLNonNull(GraphQLString)},
},
interfaces: [nodeInterface],
})

const DictionaryDefinitionType = new GraphQLObjectType<DictionaryDefinition, unknown>({
name: 'DictionaryDefinition',
fields: {
id: {
type: new GraphQLNonNull(GraphQLID),
resolve: (def) => toGlobalId('DictionaryDefinition', def.word),
},
word: {type: new GraphQLNonNull(GraphQLString)},
definition: {type: new GraphQLNonNull(GraphQLString)},
},
interfaces: [nodeInterface],
})

const {connectionType: ContactConnection} = connectionDefinitions({
name: 'Contact',
nodeType: ContactType,
})

const {connectionType: DictionaryDefinitionConnection} = connectionDefinitions({
name: 'DictionaryDefinition',
nodeType: DictionaryDefinitionType,
})

// #endregion

// #region Root Query and Schema
// =================================================================================

const schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
node: nodeField,
hello: {
type: GraphQLString,
resolve: () => 'world',
},
contacts: {
type: ContactConnection,
args: connectionArgs,
resolve: async (_, args: ConnectionArguments) => {
const contacts = await contactLoader.load('contact-info.json')
return connectionFromArray(contacts, args)
},
},
dictionary: {
type: DictionaryDefinitionConnection,
args: connectionArgs,
resolve: async (_, args: ConnectionArguments) => {
const definitions = await dictionaryLoader.load('dictionary.json')
return connectionFromArray(definitions, args)
},
},
},
}),
})

export const graphql = createHandler({schema})

// #endregion
6 changes: 6 additions & 0 deletions source/ccc-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ async function main() {
ctx.body = 'pong'
})

//
// set up the graphql endpoint
//
const {graphql} = await import('./graphql.js')
router.all('/graphql', graphql)

//
// attach middleware
//
Expand Down
Loading