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
110 changes: 110 additions & 0 deletions source/ccc-server/graphql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
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 {URLScalar} from './url-scalar.js'

interface Contact {
title: string
phoneNumber: string
buttonText: string
category: string
image?: string
synopsis: string
text: string
}

interface ContactResponse {
data: Contact[]
}

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 {nodeInterface, nodeField} = nodeDefinitions(
(globalId) => {
const {type, id} = fromGlobalId(globalId)
if (type === 'Contact') {
return contactLoader
.load('contact-info.json')
.then((contacts) => contacts.find((c) => c.title === id))
}
return null
},
(obj: object): string | undefined => {
if ('phoneNumber' in obj) {
return 'Contact'
}
return undefined
},
)

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 {connectionType: ContactConnection} = connectionDefinitions({
name: 'Contact',
nodeType: ContactType,
})

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)
},
},
},
}),
})

export const graphql = createHandler({schema})
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
107 changes: 107 additions & 0 deletions source/ccc-server/tests/graphql.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import test from 'ava'
import {spawn} from 'child_process'
import type {ChildProcess} from 'child_process'
import ky from 'ky'
import getPort from 'get-port'
import {toGlobalId} from 'graphql-relay'

let server: ChildProcess
let port: number

test.before(async () => {
port = await getPort()
await new Promise<void>((resolve, reject) => {
server = spawn('npm', ['run', 'start:dev'], {
env: {
...process.env,
INSTITUTION: 'stolaf-college',
NODE_PORT: String(port),
},
detached: true,
})

server.stdout?.on('data', (data: Buffer) => {
if (data.toString().includes(`listening on port ${String(port)}`)) {
resolve()
}
})

server.stderr?.on('data', (data: Buffer) => {
console.error(`server stderr: ${data.toString()}`)
})

server.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Server exited with code ${String(code)}`))
}
})
})
})

test.after.always(() => {
if (server.pid) {
// Kill the process group to ensure child processes are also killed.
process.kill(-server.pid)
}
})

test('graphql endpoint returns a successful response for a basic query', async (t) => {
const query = '{ hello }'
const response = await ky
.post(`http://localhost:${String(port)}/graphql`, {
json: {query},
})
.json<{data: {hello: string}}>()

t.is(response.data.hello, 'world')
})

test('graphql endpoint returns a list of contacts in a connection', async (t) => {
const query = `
query GetContacts {
contacts {
edges {
node {
id
title
}
}
}
}
`
const response = await ky
.post(`http://localhost:${String(port)}/graphql`, {
json: {query},
})
.json<{data: {contacts: {edges: {node: {id: string; title: string}}[]}}}>()

const contacts = response.data.contacts.edges
t.true(Array.isArray(contacts))
t.truthy(contacts.length)
if (contacts[0]) {
t.is(contacts[0].node.title, 'St. Olaf Public Safety')
t.truthy(contacts[0].node.id)
}
})

test('graphql endpoint can fetch a single contact by its global ID', async (t) => {
const contactTitle = 'St. Olaf Public Safety'
const globalId = toGlobalId('Contact', contactTitle)

const query = `
query GetNode($id: ID!) {
node(id: $id) {
... on Contact {
title
}
}
}
`
const response = await ky
.post(`http://localhost:${String(port)}/graphql`, {
json: {query, variables: {id: globalId}},
})
.json<{data: {node: {title: string} | null}}>()

t.is(response.data.node?.title, contactTitle)
})
Loading