Extracted routes from controllers, wrote readme's
This commit is contained in:
37
README.md
Normal file
37
README.md
Normal 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>
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
export const createGuestAccount = catchErrors(async (_req, res) => {
|
||||
const user = await createAccount();
|
||||
res.respond({
|
||||
authToken: signToken({ sub: user.id }),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
router.post(
|
||||
'/comments',
|
||||
catchErrors(async (req, res) => {
|
||||
export const create = catchErrors(async (req, res) => {
|
||||
const comment = await createEntity(Comment, req.body);
|
||||
res.respond({ comment });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
router.put(
|
||||
'/comments/:commentId',
|
||||
catchErrors(async (req, res) => {
|
||||
export const update = 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) => {
|
||||
export const remove = catchErrors(async (req, res) => {
|
||||
const comment = await deleteEntity(Comment, req.params.commentId);
|
||||
res.respond({ comment });
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
});
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import express from 'express';
|
||||
|
||||
import { Issue } from 'entities';
|
||||
import { catchErrors } from 'errors';
|
||||
import { updateEntity, deleteEntity, createEntity, findEntityOrThrow } from 'utils/typeorm';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get(
|
||||
'/issues',
|
||||
catchErrors(async (req, res) => {
|
||||
export const getProjectIssues = catchErrors(async (req, res) => {
|
||||
const { projectId } = req.currentUser;
|
||||
const { searchTerm } = req.query;
|
||||
|
||||
@@ -24,43 +18,30 @@ router.get(
|
||||
.getMany();
|
||||
|
||||
res.respond({ issues });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/issues/:issueId',
|
||||
catchErrors(async (req, res) => {
|
||||
export const getIssueWithUsersAndComments = catchErrors(async (req, res) => {
|
||||
const issue = await findEntityOrThrow(Issue, req.params.issueId, {
|
||||
relations: ['users', 'comments', 'comments.user'],
|
||||
});
|
||||
res.respond({ issue });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/issues',
|
||||
catchErrors(async (req, res) => {
|
||||
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.put(
|
||||
'/issues/:issueId',
|
||||
catchErrors(async (req, res) => {
|
||||
export const update = 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) => {
|
||||
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;
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
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();
|
||||
|
||||
router.get(
|
||||
'/project',
|
||||
catchErrors(async (req, res) => {
|
||||
export const getProjectWithUsersAndIssues = catchErrors(async (req, res) => {
|
||||
const project = await findEntityOrThrow(Project, req.currentUser.projectId, {
|
||||
relations: ['users', 'issues'],
|
||||
});
|
||||
@@ -19,15 +13,9 @@ router.get(
|
||||
issues: project.issues.map(issuePartial),
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
router.put(
|
||||
'/project',
|
||||
catchErrors(async (req, res) => {
|
||||
export const update = catchErrors(async (req, res) => {
|
||||
const project = await updateEntity(Project, req.currentUser.projectId, req.body);
|
||||
res.respond({ project });
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
router.delete(
|
||||
'/test/reset-database',
|
||||
catchErrors(async (_req, res) => {
|
||||
await resetDatabase();
|
||||
export const resetDatabase = catchErrors(async (_req, res) => {
|
||||
await resetTestDatabase();
|
||||
res.respond(true);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/test/create-account',
|
||||
catchErrors(async (_req, res) => {
|
||||
export const createAccount = catchErrors(async (_req, res) => {
|
||||
const user = await createTestAccount();
|
||||
res.respond({
|
||||
authToken: signToken({ sub: user.id }),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
});
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import express from 'express';
|
||||
|
||||
import { catchErrors } from 'errors';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get(
|
||||
'/currentUser',
|
||||
catchErrors((req, res) => {
|
||||
export const getCurrentUser = catchErrors((req, res) => {
|
||||
res.respond({ currentUser: req.currentUser });
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
});
|
||||
|
||||
@@ -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}`));
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
8
api/src/middleware/response.ts
Normal file
8
api/src/middleware/response.ts
Normal 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
32
api/src/routes.ts
Normal 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);
|
||||
};
|
||||
@@ -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. |
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user