create-epic-graphql-server 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,80 @@
1
+ const { expect } = require('chai')
2
+ import { orderSchema } from '../src/models/Order'
3
+ import { Product, Status } from '../src/typing/enums'
4
+
5
+ describe('orderSchema', () => {
6
+ it('validates a correct order object', () => {
7
+ const input = {
8
+ name: Product.productOne,
9
+ status: Status.processing,
10
+ customerId: 'abc123',
11
+ notes: 'Fragile item',
12
+ }
13
+
14
+ const result = orderSchema.safeParse(input)
15
+ expect(result.success).to.be.true
16
+ if (result.success) {
17
+ expect(result.data).to.deep.include(input)
18
+ }
19
+ })
20
+
21
+ it('fails if required fields are missing', () => {
22
+ const input = {
23
+ name: Product.productTwo,
24
+ }
25
+
26
+ const result = orderSchema.safeParse(input)
27
+ expect(result.success).to.be.false
28
+ if (!result.success) {
29
+ const errors = result.error.flatten().fieldErrors
30
+ expect(errors).to.have.property('status')
31
+ expect(errors).to.have.property('customerId')
32
+ }
33
+ })
34
+
35
+ it('fails if name or status is not a valid enum', () => {
36
+ const input = {
37
+ name: 'Invalid Product',
38
+ status: 'Invalid Status',
39
+ customerId: 'abc123',
40
+ }
41
+
42
+ const result = orderSchema.safeParse(input)
43
+ expect(result.success).to.be.false
44
+ if (!result.success) {
45
+ const errors = result.error.flatten().fieldErrors
46
+ expect(errors.name?.[0]).to.include('Invalid enum value')
47
+ expect(errors.status?.[0]).to.include('Invalid enum value')
48
+ }
49
+ })
50
+
51
+ it('accepts when optional fields are omitted', () => {
52
+ const input = {
53
+ name: Product.productThree,
54
+ status: Status.sent,
55
+ customerId: 'xyz789',
56
+ }
57
+
58
+ const result = orderSchema.safeParse(input)
59
+ expect(result.success).to.be.true
60
+ if (result.success) {
61
+ expect(result.data.notes).to.be.undefined
62
+ expect(result.data.id).to.be.undefined
63
+ }
64
+ })
65
+
66
+ it('accepts if id is provided but optional', () => {
67
+ const input = {
68
+ id: 'order-001',
69
+ name: Product.productTwo,
70
+ status: Status.notStarted,
71
+ customerId: 'cust001',
72
+ }
73
+
74
+ const result = orderSchema.safeParse(input)
75
+ expect(result.success).to.be.true
76
+ if (result.success) {
77
+ expect(result.data.id).to.equal('order-001')
78
+ }
79
+ })
80
+ })
@@ -0,0 +1,92 @@
1
+
2
+ const { expect } = require('chai')
3
+ const sinon = require('sinon')
4
+ const logger = require('../src/utilities/logger')
5
+ import type { SinonStub, SinonFakeTimers } from 'sinon'
6
+
7
+ describe('CustomLogger', () => {
8
+ let infoStub: SinonStub
9
+ let errorStub: SinonStub
10
+ let warnStub: SinonStub
11
+ let clock: SinonFakeTimers
12
+
13
+ beforeEach(() => {
14
+ if (!logger.log) {
15
+ throw new Error('logger.log must be initialized before tests')
16
+ }
17
+
18
+ infoStub = sinon.stub(logger.log, 'info')
19
+ errorStub = sinon.stub(logger.log, 'error')
20
+ warnStub = sinon.stub(logger.log, 'warn')
21
+ clock = sinon.useFakeTimers(new Date('2025-06-29T12:00:00Z').getTime())
22
+ })
23
+
24
+ afterEach(() => {
25
+ infoStub.restore()
26
+ errorStub.restore()
27
+ warnStub.restore()
28
+ clock.restore()
29
+ })
30
+
31
+ it('logs a timestamp with default message', () => {
32
+ logger.logTimeStamp()
33
+
34
+ expect(infoStub.calledOnce).to.be.true
35
+ const logged = infoStub.firstCall.args[0]
36
+ expect(logged).to.match(/\[\d{1,2}\/\d{1,2}\/\d{2}, 12:00:00/)
37
+ })
38
+
39
+ it('logs a timestamp with a custom message', () => {
40
+ logger.logTimeStamp({ msg: 'Started at' })
41
+
42
+ expect(infoStub.calledOnce).to.be.true
43
+ const logged = infoStub.firstCall.args[0]
44
+ expect(logged).to.include('Started at')
45
+ expect(logged).to.match(/\[\d{1,2}\/\d{1,2}\/\d{2}, 12:00:00/)
46
+ })
47
+
48
+ it('logs environment with custom message', () => {
49
+ logger.logEnv({ msg: 'Server started' })
50
+
51
+ expect(infoStub.calledOnce).to.be.true
52
+ const logged = infoStub.firstCall.args[0]
53
+ expect(logged).to.include('Server started')
54
+ expect(logged).to.include('[development]')
55
+ })
56
+
57
+ it('logs an error with a header and Error instance', () => {
58
+ logger.logErr({
59
+ header: 'TestHeader',
60
+ err: new Error('Something went wrong'),
61
+ })
62
+
63
+ expect(errorStub.calledOnce).to.be.true
64
+ const [message] = errorStub.firstCall.args
65
+ expect(message).to.equal('TestHeader: Something went wrong')
66
+ })
67
+
68
+ it('logs a warning with custom level', () => {
69
+ logger.logErr({
70
+ header: 'WarnHeader',
71
+ err: new Error('This is a warning'),
72
+ level: 'warn',
73
+ meta: { cause: 'testing' },
74
+ })
75
+
76
+ expect(warnStub.calledOnce).to.be.true
77
+ const [message, meta] = warnStub.firstCall.args
78
+ expect(message).to.equal('WarnHeader: This is a warning')
79
+ expect(meta).to.deep.equal({ cause: 'testing' })
80
+ })
81
+
82
+ it('logs unexpected error types gracefully', () => {
83
+ logger.logErr({
84
+ header: 'Oops',
85
+ err: 'some string error',
86
+ })
87
+
88
+ expect(errorStub.calledOnce).to.be.true
89
+ const [message] = errorStub.firstCall.args
90
+ expect(message).to.equal('unexpected Oops: some string error')
91
+ })
92
+ })
@@ -0,0 +1,103 @@
1
+ const { expect } = require('chai')
2
+ const request = require('supertest')
3
+ const sinon = require('sinon')
4
+ const express = require('express')
5
+ const { createHandler } = require('graphql-http/lib/use/express')
6
+ const { schema } = require('../src/schema/schema')
7
+ const db = require('../src/config/db')
8
+ import type { SinonStub } from 'sinon'
9
+
10
+ const app = express()
11
+ app.use('/graphql', createHandler({ schema }))
12
+
13
+ describe('GraphQL Schema', () => {
14
+ let saveStub: SinonStub
15
+ let findStub: SinonStub
16
+
17
+ beforeEach(() => {
18
+ // Stub db methods
19
+ saveStub = sinon.stub(db.customers, 'save').resolves({
20
+ id: '1',
21
+ name: 'Alice',
22
+ email: 'alice@example.com',
23
+ phone: '123-456',
24
+ })
25
+
26
+ findStub = sinon.stub(db.customers, 'find').resolves([
27
+ {
28
+ id: '1',
29
+ name: 'Alice',
30
+ email: 'alice@example.com',
31
+ phone: '123-456',
32
+ },
33
+ ])
34
+ })
35
+
36
+ afterEach(() => {
37
+ sinon.restore()
38
+ })
39
+
40
+ it('returns customers', async () => {
41
+ const query = {
42
+ query: `
43
+ query {
44
+ customers {
45
+ id
46
+ name
47
+ email
48
+ phone
49
+ }
50
+ }
51
+ `,
52
+ }
53
+
54
+ const res = await request(app)
55
+ .post('/graphql')
56
+ .send(query)
57
+ .set('Accept', 'application/json')
58
+
59
+ expect(res.statusCode).to.equal(200)
60
+ expect(res.body).to.have.nested.property('data.customers').that.is.an('array')
61
+ expect(res.body.data.customers[0]).to.include({
62
+ id: '1',
63
+ name: 'Alice',
64
+ email: 'alice@example.com',
65
+ phone: '123-456',
66
+ })
67
+ })
68
+
69
+ it('adds a customer', async () => {
70
+ const mutation = {
71
+ query: `
72
+ mutation {
73
+ addCustomer(name: "Alice", email: "alice@example.com", phone: "123-456") {
74
+ id
75
+ name
76
+ email
77
+ phone
78
+ }
79
+ }
80
+ `,
81
+ }
82
+
83
+ const res = await request(app)
84
+ .post('/graphql')
85
+ .send(mutation)
86
+ .set('Accept', 'application/json')
87
+
88
+ expect(res.statusCode).to.equal(200)
89
+ expect(res.body).to.have.nested.property('data.addCustomer').that.includes({
90
+ id: '1',
91
+ name: 'Alice',
92
+ email: 'alice@example.com',
93
+ phone: '123-456',
94
+ })
95
+
96
+ expect(saveStub.calledOnce).to.be.true
97
+ expect(saveStub.firstCall.args[0]).to.include({
98
+ name: 'Alice',
99
+ email: 'alice@example.com',
100
+ phone: '123-456',
101
+ })
102
+ })
103
+ })
@@ -0,0 +1,267 @@
1
+ const path = require('path')
2
+ const z = require('zod')
3
+ const { log, logErr } = require('../utilities/logger')
4
+ const { customerSchema } = require('../models/Customer')
5
+ const { orderSchema } = require('../models/Order')
6
+ import type { Customer } from '../models/Customer'
7
+ import type { Order } from '../models/Order'
8
+ import { DataSet } from '../typing/enums'
9
+ import type { Id, Err } from '../typing/types'
10
+ import type * as I from '../typing/interfaces'
11
+
12
+ require('dotenv-flow').config()
13
+
14
+ const crud = {
15
+ url: `http://localhost:${process.env.JSON_SERVER_PORT || 3210}`,
16
+ headers: {
17
+ 'Content-Type': 'application/json',
18
+ },
19
+ datasets: { ...DataSet },
20
+ errors: {
21
+ throw({ response }: { response: Response }) {
22
+ throw new Error(`fetch failed with status ${response.status}`)
23
+ },
24
+ handle({ err }: { err: Err }) {
25
+ logErr({ header: 'fetch error', err })
26
+ },
27
+ },
28
+ async create({ dataSet, doc }: I.Create) {
29
+ const resource = path.join(this.url, dataSet)
30
+ const options = {
31
+ method: 'POST',
32
+ headers: this.headers,
33
+ body: JSON.stringify(doc),
34
+ }
35
+
36
+ let result
37
+
38
+ try {
39
+ const response: Response = await fetch(resource, options)
40
+
41
+ if (!response.ok) this.errors.throw({ response })
42
+
43
+ result = await response.json()
44
+ } catch (err: unknown) {
45
+ this.errors.handle({ err })
46
+ }
47
+
48
+ return result
49
+ },
50
+ async read({ dataSet, filter }: I.Read) {
51
+ let resource = path.join(this.url, dataSet)
52
+
53
+ // add "filter" as query params
54
+ if (filter && Object.keys(filter).length) {
55
+ const searchParams = new URLSearchParams(
56
+ Object.entries(filter)
57
+ )
58
+
59
+ resource += '?' + searchParams.toString()
60
+ }
61
+
62
+ let result: Customer[] | Order[]
63
+
64
+ try {
65
+ const response: Response = await fetch(resource)
66
+
67
+ if (!response.ok) this.errors.throw({ response })
68
+
69
+ result = await response.json()
70
+
71
+ return result
72
+ } catch (err: unknown) {
73
+ this.errors.handle({ err })
74
+ }
75
+ },
76
+ async readOne({ dataSet, id }: I.ReadOne) {
77
+ const resource = path.join(this.url, dataSet, id)
78
+
79
+ let result: Customer | Order
80
+
81
+ try {
82
+ const response: Response = await fetch(resource)
83
+
84
+ if (!response.ok) this.errors.throw({ response })
85
+
86
+ result = await response.json()
87
+
88
+ return result
89
+ } catch (err: unknown) {
90
+ this.errors.handle({ err })
91
+ }
92
+ },
93
+ async update({ dataSet, id, update }: I.Update) {
94
+ const resource = path.join(this.url, dataSet, id)
95
+ const options = {
96
+ method: 'PATCH',
97
+ headers: this.headers,
98
+ body: JSON.stringify(update),
99
+ }
100
+
101
+ let result
102
+
103
+ try {
104
+ const response: Response = await fetch(resource, options)
105
+
106
+ if (!response.ok) this.errors.throw({ response })
107
+
108
+ result = await response.json()
109
+ } catch (err: unknown) {
110
+ this.errors.handle({ err })
111
+ }
112
+
113
+ return result
114
+ },
115
+ async delete({ dataSet, id }: I.Delete) {
116
+ const resource = path.join(this.url, dataSet, id)
117
+ const options = {
118
+ method: 'DELETE',
119
+ headers: this.headers,
120
+ }
121
+
122
+ let result
123
+
124
+ try {
125
+ const response: Response = await fetch(resource, options)
126
+
127
+ if (!response.ok) this.errors.throw({ response })
128
+
129
+ result = await response.json()
130
+ } catch (err: unknown) {
131
+ this.errors.handle({ err })
132
+ }
133
+
134
+ return result
135
+ },
136
+ }
137
+
138
+ export = {
139
+ [crud.datasets.customers]: {
140
+ async save(doc: Customer) {
141
+ let customer: Customer | undefined
142
+
143
+ try {
144
+ customer = customerSchema.parse({
145
+ name: doc.name,
146
+ email: doc.email,
147
+ phone: doc.phone,
148
+ })
149
+ } catch (err: typeof z.ZodError | unknown) {
150
+ if(err instanceof z.ZodError) {
151
+ err.issues.forEach((err: typeof z.ZodError, index: number) => {
152
+ log.error(`${err.path[index]} ${err.message}`, { index })
153
+ })
154
+ } else {
155
+ logErr({ header: 'customer parse error', err })
156
+ }
157
+ }
158
+
159
+ if (customer) {
160
+ const result = await crud.create({
161
+ dataSet: crud.datasets.customers,
162
+ doc: customer,
163
+ })
164
+
165
+ return result
166
+ }
167
+ },
168
+ async find({ filter }: I.FilterOptions) {
169
+ const result = await crud.read({
170
+ dataSet: crud.datasets.customers,
171
+ filter,
172
+ })
173
+
174
+ return result
175
+ },
176
+ async findById(id: Id) {
177
+ const result = await crud.readOne({
178
+ dataSet: crud.datasets.customers,
179
+ id,
180
+ })
181
+
182
+ return result
183
+ },
184
+ async findByIdAndUpdate(id: Id, { update }: I.UpdateOptions) {
185
+ const result = await crud.update({
186
+ dataSet: crud.datasets.customers,
187
+ id,
188
+ update,
189
+ })
190
+
191
+ return result
192
+ },
193
+ async findByIdAndRemove(id: Id) {
194
+ const result = await crud.delete({
195
+ dataSet: crud.datasets.customers,
196
+ id,
197
+ })
198
+
199
+ return result
200
+ },
201
+ },
202
+ [crud.datasets.orders]: {
203
+ async save(doc: Order) {
204
+ let order: Order | undefined
205
+
206
+ try {
207
+ order = orderSchema.parse({
208
+ name: doc.name,
209
+ ...(doc.notes && { notes: doc.notes }),
210
+ status: doc.status,
211
+ customerId: doc.customerId,
212
+ })
213
+ } catch (err: typeof z.ZodError | unknown) {
214
+ if (err instanceof z.ZodError) {
215
+ err.issues.forEach((err: typeof z.ZodError, index: number) => {
216
+ log.error(`${err.path[index]} ${err.message}`, { index })
217
+ })
218
+ } else {
219
+ logErr({ header: 'order parse error', err })
220
+ }
221
+ }
222
+
223
+ if (order) {
224
+ const result = await crud.create({
225
+ dataSet: crud.datasets.orders,
226
+ doc: order,
227
+ })
228
+
229
+ return result
230
+ }
231
+ },
232
+ async find({ filter }: I.FilterOptions) {
233
+ const result = await crud.read({
234
+ dataSet: crud.datasets.orders,
235
+ filter,
236
+ })
237
+
238
+ return result
239
+ },
240
+ async findById(id: Id) {
241
+ console.log(id)
242
+ const result = await crud.readOne({
243
+ dataSet: crud.datasets.orders,
244
+ id,
245
+ })
246
+
247
+ return result
248
+ },
249
+ async findByIdAndUpdate(id: Id, { update }: I.UpdateOptions) {
250
+ const result = await crud.update({
251
+ dataSet: crud.datasets.orders,
252
+ id,
253
+ update,
254
+ })
255
+
256
+ return result
257
+ },
258
+ async findByIdAndRemove(id: Id) {
259
+ const result = await crud.delete({
260
+ dataSet: crud.datasets.orders,
261
+ id,
262
+ })
263
+
264
+ return result
265
+ },
266
+ },
267
+ }
package/src/index.ts ADDED
@@ -0,0 +1,57 @@
1
+ const express = require('express')
2
+ const graphqlRouter = express.Router()
3
+ const cors = require('cors')
4
+ const helmet = require('helmet')
5
+ const chalk = require('chalk')
6
+ const { createHandler } = require('graphql-http/lib/use/express')
7
+ const { ruruHTML } = require('ruru/server')
8
+ const routes = require('./routes')
9
+ const { schema } = require('./schema/schema')
10
+ const logger = require('./utilities/logger')
11
+ import type { Application, Request, Response } from 'express'
12
+ import { NodeEnv } from './typing/enums'
13
+
14
+ require('dotenv-flow').config()
15
+
16
+ const { production, development } = NodeEnv
17
+
18
+ const PORT = process.env.PORT || 3000
19
+ const NODE_ENV = process.env.NODE_ENV || development
20
+
21
+ const app: Application = express()
22
+
23
+ app.use(cors())
24
+
25
+ if (NODE_ENV === production) {
26
+ app.use(helmet())
27
+ } else {
28
+ app.use(
29
+ helmet({
30
+ contentSecurityPolicy: false,
31
+ })
32
+ )
33
+ }
34
+
35
+ app.use(routes)
36
+
37
+ graphqlRouter.all('/',
38
+ createHandler({
39
+ schema,
40
+ })
41
+ )
42
+
43
+ app.use('/graphql', graphqlRouter)
44
+
45
+ if (NODE_ENV === development) {
46
+ // ruru GraphiQL runs on the endpoint '/' not '/graphql'
47
+ app.get('/', (req: Request, res: Response) => {
48
+ res.type('html')
49
+ res.end(ruruHTML({
50
+ endpoint: '/graphql',
51
+ }))
52
+ })
53
+ }
54
+
55
+ app.listen(PORT, () => logger.logEnv({
56
+ msg: `running on ${chalk.blue.bold(PORT)}`,
57
+ }))
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod'
2
+
3
+ export const customerSchema = z.object({
4
+ id: z.string().optional(), // optional? let db add id on save
5
+ name: z.string(),
6
+ email: z.string().email(),
7
+ phone: z.string(),
8
+ })
9
+
10
+ export type Customer = z.infer<typeof customerSchema>
@@ -0,0 +1,12 @@
1
+ import { z } from 'zod'
2
+ import { Product, Status } from '../typing/enums'
3
+
4
+ export const orderSchema = z.object({
5
+ id: z.string().optional(), // optional? let db add id on save
6
+ name: z.nativeEnum(Product),
7
+ notes: z.string().optional(),
8
+ status: z.nativeEnum(Status),
9
+ customerId: z.string(),
10
+ })
11
+
12
+ export type Order = z.infer<typeof orderSchema>
@@ -0,0 +1,18 @@
1
+ const express = require('express')
2
+ const router = express.Router()
3
+ import type { Request, Response } from 'express'
4
+ import { NodeEnv } from '../typing/enums'
5
+
6
+ require('dotenv-flow').config()
7
+
8
+ // NOTE remove this if you wish, it won't affect the graphql route,
9
+ // this is just here to test express and typescript live transpile,
10
+ // but also demonstrates a conventional routing pattern if needed
11
+
12
+ if (process.env.NODE_ENV === NodeEnv.development) {
13
+ router.get('/check', (req: Request, res: Response) => {
14
+ res.status(200).send('okay...')
15
+ })
16
+ }
17
+
18
+ export = router
@@ -0,0 +1,8 @@
1
+ const express = require('express')
2
+ const router = express.Router()
3
+
4
+ router.use(
5
+ require('./check')
6
+ )
7
+
8
+ export = router