Skip to content

Commit 51f6258

Browse files
committed
nextjs typegraphql apollo server micro
1 parent ab42e97 commit 51f6258

25 files changed

+4783
-63
lines changed

.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
MONGO_INITDB_ROOT_USERNAME=
2+
MONGO_INITDB_ROOT_PASSWORD=
3+
MONGO_INITDB_DATABASE=
4+
MONGODB_LOCAL_URI=
5+
6+
ACCESS_TOKEN_PRIVATE_KEY=
7+
ACCESS_TOKEN_PUBLIC_KEY=
8+
9+
REFRESH_TOKEN_PRIVATE_KEY=
10+
REFRESH_TOKEN_PUBLIC_KEY=

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ yarn-error.log*
2727

2828
# local env files
2929
.env*.local
30+
.env
3031

3132
# vercel
3233
.vercel

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
dev:
2+
docker-compose up -d
3+
4+
dev-down:
5+
docker-compose down

codegen.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
schema: http://localhost:3000/api/graphql
2+
documents: './src/**/*.graphql'
3+
generates:
4+
./graphql/generated.ts:
5+
plugins:
6+
- typescript
7+
- typescript-operations
8+
- typescript-react-query
9+
config:
10+
fetcher: graphql-request

docker-compose.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
version: '3.8'
2+
services:
3+
mongo:
4+
image: mongo
5+
container_name: mongodb
6+
ports:
7+
- '6000:27017'
8+
volumes:
9+
- mongodb:/data/db
10+
env_file:
11+
- ./.env
12+
environment:
13+
MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME}
14+
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD}
15+
MONGO_INITDB_DATABASE: ${MONGO_INITDB_DATABASE}
16+
redis:
17+
image: redis:latest
18+
container_name: redis
19+
ports:
20+
- '6379:6379'
21+
volumes:
22+
- redis:/data
23+
volumes:
24+
redis:
25+
mongodb:

next.config.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
/** @type {import('next').NextConfig} */
22
const nextConfig = {
33
reactStrictMode: true,
4-
}
4+
webpack: (config) => {
5+
config.experiments = config.experiments || {};
6+
config.experiments.topLevelAwait = true;
7+
return config;
8+
},
9+
};
510

6-
module.exports = nextConfig
11+
module.exports = nextConfig;

package.json

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,41 @@
99
"lint": "next lint"
1010
},
1111
"dependencies": {
12+
"@typegoose/typegoose": "^9.9.0",
13+
"apollo-server-micro": "^3.9.0",
14+
"bcryptjs": "^2.4.3",
15+
"class-validator": "^0.13.2",
16+
"cookies-next": "^2.0.4",
17+
"cors": "^2.8.5",
18+
"dotenv-safe": "^8.2.0",
19+
"graphql": "15.x",
20+
"graphql-request": "^4.3.0",
21+
"jsonwebtoken": "^8.5.1",
22+
"micro": "^9.3.4",
23+
"mongoose": "^6.4.0",
1224
"next": "12.1.6",
1325
"react": "18.2.0",
14-
"react-dom": "18.2.0"
26+
"react-dom": "18.2.0",
27+
"redis": "^4.1.0",
28+
"reflect-metadata": "^0.1.13",
29+
"type-graphql": "^1.1.1"
1530
},
1631
"devDependencies": {
32+
"@graphql-codegen/cli": "^2.6.2",
33+
"@graphql-codegen/typescript": "^2.5.1",
34+
"@graphql-codegen/typescript-operations": "^2.4.2",
35+
"@graphql-codegen/typescript-react-query": "^3.5.14",
36+
"@types/bcryptjs": "^2.4.2",
37+
"@types/dotenv-safe": "^8.1.2",
38+
"@types/jsonwebtoken": "^8.5.8",
1739
"@types/node": "18.0.0",
1840
"@types/react": "18.0.14",
1941
"@types/react-dom": "18.0.5",
42+
"autoprefixer": "^10.4.7",
2043
"eslint": "8.18.0",
2144
"eslint-config-next": "12.1.6",
45+
"postcss": "^8.4.14",
46+
"tailwindcss": "^3.1.3",
2247
"typescript": "4.7.4"
2348
}
2449
}

pages/_document.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Html, Head, Main, NextScript } from 'next/document';
2+
import dotenv from 'dotenv-safe';
3+
dotenv.config();
4+
5+
export default function Document() {
6+
return (
7+
<Html>
8+
<Head />
9+
<body>
10+
<Main />
11+
<NextScript />
12+
</body>
13+
</Html>
14+
);
15+
}

pages/api/graphql.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import 'reflect-metadata';
2+
import { NextApiRequest, NextApiResponse } from 'next';
3+
import { ApolloServer } from 'apollo-server-micro';
4+
import { buildSchema } from 'type-graphql';
5+
import Cors from 'cors';
6+
import { resolvers } from '../../src/resolvers';
7+
import { connectDB } from '../../src/utils/connectDB';
8+
import deserializeUser from '../../src/middleware/deserializeUser';
9+
10+
const cors = Cors({
11+
methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
12+
credentials: true,
13+
origin: [
14+
'https://studio.apollographql.com',
15+
'http://localhost:8000',
16+
'http://localhost:3000',
17+
],
18+
});
19+
20+
function runMiddleware(req: NextApiRequest, res: NextApiResponse, fn: any) {
21+
return new Promise((resolve, reject) => {
22+
fn(req, res, (result: any) => {
23+
if (result instanceof Error) {
24+
return reject(result);
25+
}
26+
27+
return resolve(result);
28+
});
29+
});
30+
}
31+
32+
const schema = await buildSchema({
33+
resolvers,
34+
dateScalarMode: 'isoDate',
35+
});
36+
37+
const server = new ApolloServer({
38+
schema,
39+
csrfPrevention: true,
40+
context: ({ req, res }: { req: NextApiRequest; res: NextApiResponse }) => ({
41+
req,
42+
res,
43+
deserializeUser,
44+
}),
45+
});
46+
47+
export const config = {
48+
api: {
49+
bodyParser: false,
50+
},
51+
};
52+
53+
const startServer = server.start();
54+
55+
export default async function handler(
56+
req: NextApiRequest,
57+
res: NextApiResponse
58+
) {
59+
await runMiddleware(req, res, cors);
60+
await connectDB();
61+
await startServer;
62+
await server.createHandler({ path: '/api/graphql' })(req, res);
63+
}

postcss.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = {
2+
plugins: {
3+
tailwindcss: {},
4+
autoprefixer: {},
5+
},
6+
}

src/controllers/error.controller.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { ValidationError } from 'apollo-server-micro';
2+
3+
const handleCastError = (error: any) => {
4+
const message = `Invalid ${error.path}: ${error.value}`;
5+
throw new ValidationError(message);
6+
};
7+
8+
const handleValidationError = (error: any) => {
9+
const message = Object.values(error.errors).map((el: any) => el.message);
10+
throw new ValidationError(`Invalid input: ${message.join(', ')}`);
11+
};
12+
13+
const errorHandler = (err: any) => {
14+
if (err.name === 'CastError') handleCastError(err);
15+
if (err.name === 'ValidationError') handleValidationError(err);
16+
throw err;
17+
};
18+
19+
export default errorHandler;

src/middleware/deserializeUser.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { AuthenticationError, ForbiddenError } from 'apollo-server-micro';
2+
import { NextApiRequest, NextApiResponse } from 'next';
3+
import { checkCookies, getCookie } from 'cookies-next';
4+
import errorHandler from '../controllers/error.controller';
5+
import UserModel from '../models/user.model';
6+
import redisClient from '../utils/connectRedis';
7+
import { verifyJwt } from '../utils/jwt';
8+
import { disconnectDB } from '../utils/connectDB';
9+
10+
const deserializeUser = async (req: NextApiRequest, res: NextApiResponse) => {
11+
try {
12+
// Get the access token
13+
let access_token;
14+
if (
15+
req.headers.authorization &&
16+
req.headers.authorization.startsWith('Bearer')
17+
) {
18+
access_token = req.headers.authorization.split(' ')[1];
19+
} else if (checkCookies('access_token', { req, res })) {
20+
access_token = getCookie('access_token', { req, res });
21+
}
22+
23+
if (!access_token) throw new AuthenticationError('No access token found');
24+
25+
// Validate the Access token
26+
const decoded = verifyJwt<{ userId: string }>(
27+
String(access_token),
28+
'accessTokenPublicKey'
29+
);
30+
31+
if (!decoded) throw new AuthenticationError('Invalid access token');
32+
33+
// Check if the session is valid
34+
const session = await redisClient.get(decoded.userId);
35+
36+
if (!session) throw new ForbiddenError('Session has expired');
37+
38+
// Check if user exist
39+
const user = await UserModel.findById(JSON.parse(session)._id)
40+
.select('+verified')
41+
.lean(true);
42+
await disconnectDB();
43+
44+
if (!user || !user.verified) {
45+
throw new ForbiddenError(
46+
'The user belonging to this token no logger exist'
47+
);
48+
}
49+
50+
return user;
51+
} catch (error: any) {
52+
errorHandler(error);
53+
}
54+
};
55+
56+
export default deserializeUser;

src/models/user.model.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {
2+
getModelForClass,
3+
prop,
4+
pre,
5+
ModelOptions,
6+
Severity,
7+
index,
8+
} from '@typegoose/typegoose';
9+
import bcrypt from 'bcryptjs';
10+
11+
@pre<User>('save', async function (next) {
12+
if (!this.isModified('password')) return next();
13+
14+
this.password = await bcrypt.hash(this.password, 12);
15+
this.passwordConfirm = undefined;
16+
return next();
17+
})
18+
@ModelOptions({
19+
schemaOptions: {
20+
timestamps: true,
21+
},
22+
options: {
23+
allowMixed: Severity.ALLOW,
24+
},
25+
})
26+
@index({ email: 1 })
27+
export class User {
28+
readonly _id: string;
29+
30+
@prop({ required: true })
31+
name: string;
32+
33+
@prop({ required: true, unique: true, lowercase: true })
34+
email: string;
35+
36+
@prop({ default: 'user' })
37+
role: string;
38+
39+
@prop({ required: true, select: false })
40+
password: string;
41+
42+
@prop({ required: true })
43+
passwordConfirm: string | undefined;
44+
45+
@prop({ default: 'default.jpeg' })
46+
photo: string;
47+
48+
@prop({ default: true, select: false })
49+
verified: boolean;
50+
51+
static async comparePasswords(
52+
hashedPassword: string,
53+
candidatePassword: string
54+
) {
55+
return await bcrypt.compare(candidatePassword, hashedPassword);
56+
}
57+
}
58+
59+
const UserModel = getModelForClass<typeof User>(User);
60+
export default UserModel;

src/resolvers/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import UserResolver from './user.resolver';
2+
3+
export const resolvers = [UserResolver] as const;

src/resolvers/user.resolver.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Arg, Ctx, Mutation, Query, Resolver } from 'type-graphql';
2+
import {
3+
LoginInput,
4+
LoginResponse,
5+
SignUpInput,
6+
UserResponse,
7+
} from '../schemas/user.schema';
8+
import UserService from '../services/user.service';
9+
import type { Context } from '../types/context';
10+
11+
@Resolver()
12+
export default class UserResolver {
13+
constructor(private userService: UserService) {
14+
this.userService = new UserService();
15+
}
16+
17+
@Mutation(() => UserResponse)
18+
async signupUser(@Arg('input') input: SignUpInput) {
19+
return this.userService.signUpUser(input);
20+
}
21+
22+
@Mutation(() => LoginResponse)
23+
async loginUser(@Arg('input') loginInput: LoginInput, @Ctx() ctx: Context) {
24+
return this.userService.loginUser(loginInput, ctx);
25+
}
26+
27+
@Query(() => UserResponse)
28+
async getMe(@Ctx() ctx: Context) {
29+
return this.userService.getMe(ctx);
30+
}
31+
32+
@Query(() => LoginResponse)
33+
async refreshAccessToken(@Ctx() ctx: Context) {
34+
return this.userService.refreshAccessToken(ctx);
35+
}
36+
37+
@Query(() => Boolean)
38+
async logoutUser(@Ctx() ctx: Context) {
39+
return this.userService.logoutUser(ctx);
40+
}
41+
}

0 commit comments

Comments
 (0)