Extracted routes from controllers, wrote readme's

This commit is contained in:
ireic
2020-01-11 00:39:06 +01:00
parent 5cc0d49964
commit fe4ef2f981
15 changed files with 224 additions and 185 deletions

37
README.md Normal file
View File

@@ -0,0 +1,37 @@
<h1 align="center">A simplified Jira clone built with React and Node</h1>
<div align="center">Auto formatted with Prettier, tested with Cypress 🎗</div>
<h3 align="center">
<a href="https://www.codetree.co">Visit the live app</a> |
<a href="https://github.com/oldboyxx/jira_clone/tree/master/client">View client</a> |
<a href="https://github.com/oldboyxx/jira_clone/tree/master/api">View API</a>
</h3>
![Tech logos](https://i.ibb.co/w4Y9K8Z/tech.jpg)
![App screenshot](https://i.ibb.co/HDwwh6L/jira-clone-optimized.jpg)
## Setting up development environment
1. Install postgreSQL if you don't have it already and create a database named `jira_development`.
2. `git clone https://github.com/oldboyxx/jira_clone.git`
3. Create an empty `.env` file in `/api`, copy `/api/.env.example` contents into it, and fill in your database username and password.
4. `npm run install-dependencies`
5. `cd api && npm start`
6. `cd client && npm start` in another terminal tab
7. App should now be running on `http://localhost:8080/`
## Running cypress end-to-end tests
1. Set up development environment
2. Create a database named `jira_test` and start the api with `cd api && npm run start:test`
3. `cd client && npm run test:cypress`
<hr>
<h3>
<a href="https://www.codetree.co">Visit the live app</a> |
<a href="https://github.com/oldboyxx/jira_clone/tree/master/client">View client</a> |
<a href="https://github.com/oldboyxx/jira_clone/tree/master/api">View API</a>
</h3>

View File

@@ -1 +1,40 @@
# Jira clone API
# Jira clone API built with Node/TypeScript
The API codebase is fairly simple and should be easy enough to understand.
### Project structure
<br>
<!-- prettier-ignore-start -->
| File or folder | Description |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/index.ts` | The entry file. This is where we setup middleware, attach routes, initialize database and express. |
| `src/routes.ts` | This is where we define all routes, both public and private. |
| `src/constants` | Constants are values that never change and are used in multiple places across the codebase. |
| `src/controllers` | Controllers listen to client's requests and work with entities and the database to fetch, add, update, or delete data. |
| `src/database` | Database related code and seeds go here. |
| `src/entities` | This is where we put TypeORM entities, you could think of them as models. We define columns, relations, validations for each database entity. |
| `src/errors` | This is where we define custom errors. The `catchErrors` function helps us avoid repetitive `try/catch` blocks within controllers. |
| `src/middleware` | Middleware functions can modify request and response objects, end the request-response cycle, etc. For example `authenticateUser` method verifies the authorization token and attaches `currentUser` to the request object. |
| `src/serializers` | Serializers transform the data fetched from the database before it's sent to the client. |
| `src/utils` | Utility(helper) functions that are used in multiple places across the codebase. For example `utils/typeorm.ts` functions help us validate data and avoid writing repetitive code. |
<!-- prettier-ignore-end -->
<br>
### What's missing?
There are features missing from this showcase API which should exist in a real product:
#### Migrations
We're currently using TypeORM's `synchronize` feature which auto creates the database schema on every application launch. It's fine to do this in a showcase product or during early development while the product is not used by anyone, but before going live with a real product, we should [introduce migrations](https://github.com/typeorm/typeorm/blob/master/docs/migrations.md).
#### Proper authentication system
We currently auto create an auth token and seed a project with issues and users for anyone who visits the API without valid credentials. In a real product we'd want to implement a proper [email and password authentication system](https://www.google.com/search?q=email+and+password+authentication+node+js&oq=email+and+password+authentication+node+js).
#### Unit/Integration tests
This API is currently tested by the Client through [end-to-end Cypress tests](https://github.com/oldboyxx/jira_clone/tree/master/client/cypress/integration). That's good enough for a relatively simple application such as this, even if it was a real product. However, as the API grows in complexity, it might be wise to start writing additional API-specific unit/integration tests.

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"scripts": {
"start": "nodemon --exec ts-node --files src/index.ts",
"start:test": "cross-env NODE_ENV='test' DB_DATABASE='jira_test' && npm start",
"start:test": "cross-env NODE_ENV='test' DB_DATABASE='jira_test' npm start",
"start:production": "cross-env NODE_ENV=production pm2 start node -- -r ./tsconfig-paths.js build/index.js",
"build": "cd src && tsc",
"pre-commit": "lint-staged"

View File

@@ -1,19 +1,10 @@
import express from 'express';
import { catchErrors } from 'errors';
import { signToken } from 'utils/authToken';
import createGuestAccount from 'database/createGuestAccount';
import createAccount from 'database/createGuestAccount';
const router = express.Router();
router.post(
'/authentication/guest',
catchErrors(async (_req, res) => {
const user = await createGuestAccount();
res.respond({
authToken: signToken({ sub: user.id }),
});
}),
);
export default router;
export const createGuestAccount = catchErrors(async (_req, res) => {
const user = await createAccount();
res.respond({
authToken: signToken({ sub: user.id }),
});
});

View File

@@ -1,33 +1,18 @@
import express from 'express';
import { Comment } from 'entities';
import { catchErrors } from 'errors';
import { updateEntity, deleteEntity, createEntity } from 'utils/typeorm';
const router = express.Router();
export const create = catchErrors(async (req, res) => {
const comment = await createEntity(Comment, req.body);
res.respond({ comment });
});
router.post(
'/comments',
catchErrors(async (req, res) => {
const comment = await createEntity(Comment, req.body);
res.respond({ comment });
}),
);
export const update = catchErrors(async (req, res) => {
const comment = await updateEntity(Comment, req.params.commentId, req.body);
res.respond({ comment });
});
router.put(
'/comments/:commentId',
catchErrors(async (req, res) => {
const comment = await updateEntity(Comment, req.params.commentId, req.body);
res.respond({ comment });
}),
);
router.delete(
'/comments/:commentId',
catchErrors(async (req, res) => {
const comment = await deleteEntity(Comment, req.params.commentId);
res.respond({ comment });
}),
);
export default router;
export const remove = catchErrors(async (req, res) => {
const comment = await deleteEntity(Comment, req.params.commentId);
res.respond({ comment });
});

View File

@@ -1,66 +1,47 @@
import express from 'express';
import { Issue } from 'entities';
import { catchErrors } from 'errors';
import { updateEntity, deleteEntity, createEntity, findEntityOrThrow } from 'utils/typeorm';
const router = express.Router();
export const getProjectIssues = catchErrors(async (req, res) => {
const { projectId } = req.currentUser;
const { searchTerm } = req.query;
router.get(
'/issues',
catchErrors(async (req, res) => {
const { projectId } = req.currentUser;
const { searchTerm } = req.query;
let whereSQL = 'issue.projectId = :projectId';
let whereSQL = 'issue.projectId = :projectId';
if (searchTerm) {
whereSQL += ' AND (issue.title ILIKE :searchTerm OR issue.descriptionText ILIKE :searchTerm)';
}
if (searchTerm) {
whereSQL += ' AND (issue.title ILIKE :searchTerm OR issue.descriptionText ILIKE :searchTerm)';
}
const issues = await Issue.createQueryBuilder('issue')
.select()
.where(whereSQL, { projectId, searchTerm: `%${searchTerm}%` })
.getMany();
const issues = await Issue.createQueryBuilder('issue')
.select()
.where(whereSQL, { projectId, searchTerm: `%${searchTerm}%` })
.getMany();
res.respond({ issues });
});
res.respond({ issues });
}),
);
export const getIssueWithUsersAndComments = catchErrors(async (req, res) => {
const issue = await findEntityOrThrow(Issue, req.params.issueId, {
relations: ['users', 'comments', 'comments.user'],
});
res.respond({ issue });
});
router.get(
'/issues/:issueId',
catchErrors(async (req, res) => {
const issue = await findEntityOrThrow(Issue, req.params.issueId, {
relations: ['users', 'comments', 'comments.user'],
});
res.respond({ issue });
}),
);
export const create = catchErrors(async (req, res) => {
const listPosition = await calculateListPosition(req.body);
const issue = await createEntity(Issue, { ...req.body, listPosition });
res.respond({ issue });
});
router.post(
'/issues',
catchErrors(async (req, res) => {
const listPosition = await calculateListPosition(req.body);
const issue = await createEntity(Issue, { ...req.body, listPosition });
res.respond({ issue });
}),
);
export const update = catchErrors(async (req, res) => {
const issue = await updateEntity(Issue, req.params.issueId, req.body);
res.respond({ issue });
});
router.put(
'/issues/:issueId',
catchErrors(async (req, res) => {
const issue = await updateEntity(Issue, req.params.issueId, req.body);
res.respond({ issue });
}),
);
router.delete(
'/issues/:issueId',
catchErrors(async (req, res) => {
const issue = await deleteEntity(Issue, req.params.issueId);
res.respond({ issue });
}),
);
export const remove = catchErrors(async (req, res) => {
const issue = await deleteEntity(Issue, req.params.issueId);
res.respond({ issue });
});
const calculateListPosition = async ({ projectId, status }: Issue): Promise<number> => {
const issues = await Issue.find({ projectId, status });
@@ -72,5 +53,3 @@ const calculateListPosition = async ({ projectId, status }: Issue): Promise<numb
}
return 1;
};
export default router;

View File

@@ -1,33 +1,21 @@
import express from 'express';
import { Project } from 'entities';
import { catchErrors } from 'errors';
import { findEntityOrThrow, updateEntity } from 'utils/typeorm';
import { issuePartial } from 'serializers/issues';
const router = express.Router();
export const getProjectWithUsersAndIssues = catchErrors(async (req, res) => {
const project = await findEntityOrThrow(Project, req.currentUser.projectId, {
relations: ['users', 'issues'],
});
res.respond({
project: {
...project,
issues: project.issues.map(issuePartial),
},
});
});
router.get(
'/project',
catchErrors(async (req, res) => {
const project = await findEntityOrThrow(Project, req.currentUser.projectId, {
relations: ['users', 'issues'],
});
res.respond({
project: {
...project,
issues: project.issues.map(issuePartial),
},
});
}),
);
router.put(
'/project',
catchErrors(async (req, res) => {
const project = await updateEntity(Project, req.currentUser.projectId, req.body);
res.respond({ project });
}),
);
export default router;
export const update = catchErrors(async (req, res) => {
const project = await updateEntity(Project, req.currentUser.projectId, req.body);
res.respond({ project });
});

View File

@@ -1,28 +1,16 @@
import express from 'express';
import { catchErrors } from 'errors';
import { signToken } from 'utils/authToken';
import resetDatabase from 'database/resetDatabase';
import resetTestDatabase from 'database/resetDatabase';
import createTestAccount from 'database/createTestAccount';
const router = express.Router();
export const resetDatabase = catchErrors(async (_req, res) => {
await resetTestDatabase();
res.respond(true);
});
router.delete(
'/test/reset-database',
catchErrors(async (_req, res) => {
await resetDatabase();
res.respond(true);
}),
);
router.post(
'/test/create-account',
catchErrors(async (_req, res) => {
const user = await createTestAccount();
res.respond({
authToken: signToken({ sub: user.id }),
});
}),
);
export default router;
export const createAccount = catchErrors(async (_req, res) => {
const user = await createTestAccount();
res.respond({
authToken: signToken({ sub: user.id }),
});
});

View File

@@ -1,14 +1,5 @@
import express from 'express';
import { catchErrors } from 'errors';
const router = express.Router();
router.get(
'/currentUser',
catchErrors((req, res) => {
res.respond({ currentUser: req.currentUser });
}),
);
export default router;
export const getCurrentUser = catchErrors((req, res) => {
res.respond({ currentUser: req.currentUser });
});

View File

@@ -5,15 +5,12 @@ import express from 'express';
import cors from 'cors';
import createDatabaseConnection from 'database/createConnection';
import { addRespondToResponse } from 'middleware/response';
import { authenticateUser } from 'middleware/authentication';
import authenticationRoutes from 'controllers/authentication';
import commentsRoutes from 'controllers/comments';
import issuesRoutes from 'controllers/issues';
import projectsRoutes from 'controllers/projects';
import testRoutes from 'controllers/test';
import usersRoutes from 'controllers/users';
import { handleError } from 'middleware/errors';
import { RouteNotFoundError } from 'errors';
import { errorHandler } from 'errors/errorHandler';
import { attachPublicRoutes, attachPrivateRoutes } from './routes';
const establishDatabaseConnection = async (): Promise<void> => {
try {
@@ -31,28 +28,16 @@ const initializeExpress = (): void => {
app.use(express.json());
app.use(express.urlencoded());
app.use((_req, res, next) => {
res.respond = (data): void => {
res.status(200).send(data);
};
next();
});
app.use(addRespondToResponse);
if (process.env.NODE_ENV === 'test') {
app.use('/', testRoutes);
}
app.use('/', authenticationRoutes);
attachPublicRoutes(app);
app.use('/', authenticateUser);
app.use('/', commentsRoutes);
app.use('/', issuesRoutes);
app.use('/', projectsRoutes);
app.use('/', usersRoutes);
attachPrivateRoutes(app);
app.use((req, _res, next) => next(new RouteNotFoundError(req.originalUrl)));
app.use(errorHandler);
app.use(handleError);
app.listen(PORT, () => console.log(`App listening on port ${PORT}`));
};

View File

@@ -3,7 +3,7 @@ import { pick } from 'lodash';
import { CustomError } from 'errors';
export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
export const handleError: ErrorRequestHandler = (error, _req, res, _next) => {
console.error(error);
const isErrorSafeForClient = error instanceof CustomError;

View File

@@ -0,0 +1,8 @@
import { RequestHandler } from 'express';
export const addRespondToResponse: RequestHandler = (_req, res, next) => {
res.respond = (data): void => {
res.status(200).send(data);
};
next();
};

32
api/src/routes.ts Normal file
View File

@@ -0,0 +1,32 @@
import * as authentication from 'controllers/authentication';
import * as comments from 'controllers/comments';
import * as issues from 'controllers/issues';
import * as projects from 'controllers/projects';
import * as test from 'controllers/test';
import * as users from 'controllers/users';
export const attachPublicRoutes = (app: any): void => {
if (process.env.NODE_ENV === 'test') {
app.delete('/test/reset-database', test.resetDatabase);
app.post('/test/create-account', test.createAccount);
}
app.post('/authentication/guest', authentication.createGuestAccount);
};
export const attachPrivateRoutes = (app: any): void => {
app.post('/comments', comments.create);
app.put('/comments/:commentId', comments.update);
app.delete('/comments/:commentId', comments.remove);
app.get('/issues', issues.getProjectIssues);
app.get('/issues/:issueId', issues.getIssueWithUsersAndComments);
app.post('/issues', issues.create);
app.put('/issues/:issueId', issues.update);
app.delete('/issues/:issueId', issues.remove);
app.get('/project', projects.getProjectWithUsersAndIssues);
app.put('/project', projects.update);
app.get('/currentUser', users.getCurrentUser);
};

View File

@@ -0,0 +1,16 @@
# Project structure
I've used this architecture on multiple larger projects in the past and it performed really well.
There are two special root folders in `src`: `App` and `shared` (described below). All other root folders in `src` (in our case only two: `Auth` and `Project`) should follow the structure of the routes. We can call these folders modules.
The main rule to follow: **Files from one module can only import from ancestor folders within the same module or from `src/shared`.** This makes the codebase easier to understand, and if you're fiddling with code in one module, you will never introduce a bug in another module.
| File or folder | Description |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/index.jsx` | The entry file. This is where we import babel polyfills and render the App into the root DOM node. |
| `src/index.html` | The only HTML file in our App. All scripts and styles will be injected here by Webpack. |
| `src/App` | Main application routes, components that need to be mounted at all times regardless of current route, global css styles, fonts, etc. Basically anything considered global / ancestor of all modules. |
| `src/Auth` | Authentication module |
| `src/Project` | Project module |
| `src/shared` | Components, constants, utils, hooks, styles etc. that can be used anywhere in the codebase. Any module is allowed to import from shared. |

View File

@@ -16,6 +16,6 @@ describe('Authentication', () => {
.should('be.a', 'string')
.and('not.be.empty');
cy.get(testid`list-issue`).should('have.length', 7);
cy.get(testid`list-issue`).should('have.length', 8);
});
});