jaothan commited on
Commit
050d2e1
·
verified ·
1 Parent(s): c3f3f57

Upload 36 files

Browse files
.dockerignore ADDED
@@ -0,0 +1 @@
 
 
1
+ node_modules
.gitignore ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ lerna-debug.log*
8
+ .pnpm-debug.log*
9
+
10
+ # Diagnostic reports (https://nodejs.org/api/report.html)
11
+ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12
+
13
+ # Runtime data
14
+ pids
15
+ *.pid
16
+ *.seed
17
+ *.pid.lock
18
+
19
+ # Directory for instrumented libs generated by jscoverage/JSCover
20
+ lib-cov
21
+
22
+ # Coverage directory used by tools like istanbul
23
+ coverage
24
+ *.lcov
25
+
26
+ # nyc test coverage
27
+ .nyc_output
28
+
29
+ # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30
+ .grunt
31
+
32
+ # Bower dependency directory (https://bower.io/)
33
+ bower_components
34
+
35
+ # node-waf configuration
36
+ .lock-wscript
37
+
38
+ # Compiled binary addons (https://nodejs.org/api/addons.html)
39
+ build/Release
40
+
41
+ # Dependency directories
42
+ node_modules/
43
+ jspm_packages/
44
+
45
+ # Snowpack dependency directory (https://snowpack.dev/)
46
+ web_modules/
47
+
48
+ # TypeScript cache
49
+ *.tsbuildinfo
50
+
51
+ # Optional npm cache directory
52
+ .npm
53
+
54
+ # Optional eslint cache
55
+ .eslintcache
56
+
57
+ # Optional stylelint cache
58
+ .stylelintcache
59
+
60
+ # Microbundle cache
61
+ .rpt2_cache/
62
+ .rts2_cache_cjs/
63
+ .rts2_cache_es/
64
+ .rts2_cache_umd/
65
+
66
+ # Optional REPL history
67
+ .node_repl_history
68
+
69
+ # Output of 'npm pack'
70
+ *.tgz
71
+
72
+ # Yarn Integrity file
73
+ .yarn-integrity
74
+
75
+ # dotenv environment variable files
76
+ .env
77
+ .env.development.local
78
+ .env.test.local
79
+ .env.production.local
80
+ .env.local
81
+
82
+ # parcel-bundler cache (https://parceljs.org/)
83
+ .cache
84
+ .parcel-cache
85
+
86
+ # Next.js build output
87
+ .next
88
+ out
89
+
90
+ # Nuxt.js build / generate output
91
+ .nuxt
92
+ dist
93
+
94
+ # Gatsby files
95
+ .cache/
96
+ # Comment in the public line in if your project uses Gatsby and not Next.js
97
+ # https://nextjs.org/blog/next-9-1#public-directory-support
98
+ # public
99
+
100
+ # vuepress build output
101
+ .vuepress/dist
102
+
103
+ # vuepress v2.x temp and cache directory
104
+ .temp
105
+ .cache
106
+
107
+ # Docusaurus cache and generated files
108
+ .docusaurus
109
+
110
+ # Serverless directories
111
+ .serverless/
112
+
113
+ # FuseBox cache
114
+ .fusebox/
115
+
116
+ # DynamoDB Local files
117
+ .dynamodb/
118
+
119
+ # TernJS port file
120
+ .tern-port
121
+
122
+ # Stores VSCode versions used for testing VSCode extensions
123
+ .vscode-test
124
+
125
+ # yarn v2
126
+ .yarn/cache
127
+ .yarn/unplugged
128
+ .yarn/build-state.yml
129
+ .yarn/install-state.gz
130
+ .pnp.*
Dockerfile ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ###################################################
2
+ # Stage: base
3
+ #
4
+ # This base stage ensures all other stages are using the same base image
5
+ # and provides common configuration for all stages, such as the working dir.
6
+ ###################################################
7
+ FROM node:20 AS base
8
+ WORKDIR /usr/local/app
9
+
10
+ ################## CLIENT STAGES ##################
11
+
12
+ ###################################################
13
+ # Stage: client-base
14
+ #
15
+ # This stage is used as the base for the client-dev and client-build stages,
16
+ # since there are common steps needed for each.
17
+ ###################################################
18
+ FROM base AS client-base
19
+ COPY client/package.json client/yarn.lock ./
20
+ RUN --mount=type=cache,id=yarn,target=/usr/local/share/.cache/yarn \
21
+ yarn install
22
+ COPY client/.eslintrc.cjs client/index.html client/vite.config.js ./
23
+ COPY client/public ./public
24
+ COPY client/src ./src
25
+
26
+ ###################################################
27
+ # Stage: client-dev
28
+ #
29
+ # This stage is used for development of the client application. It sets
30
+ # the default command to start the Vite development server.
31
+ ###################################################
32
+ FROM client-base AS client-dev
33
+ CMD ["yarn", "dev"]
34
+
35
+ ###################################################
36
+ # Stage: client-build
37
+ #
38
+ # This stage builds the client application, producing static HTML, CSS, and
39
+ # JS files that can be served by the backend.
40
+ ###################################################
41
+ FROM client-base AS client-build
42
+ RUN yarn build
43
+
44
+
45
+
46
+
47
+ ###################################################
48
+ ################ BACKEND STAGES #################
49
+ ###################################################
50
+
51
+ ###################################################
52
+ # Stage: backend-base
53
+ #
54
+ # This stage is used as the base for the backend-dev and test stages, since
55
+ # there are common steps needed for each.
56
+ ###################################################
57
+ FROM base AS backend-dev
58
+ COPY backend/package.json backend/yarn.lock ./
59
+ RUN --mount=type=cache,id=yarn,target=/usr/local/share/.cache/yarn \
60
+ yarn install --frozen-lockfile
61
+ COPY backend/spec ./spec
62
+ COPY backend/src ./src
63
+ CMD ["yarn", "dev"]
64
+
65
+ ###################################################
66
+ # Stage: test
67
+ #
68
+ # This stage runs the tests on the backend. This is split into a separate
69
+ # stage to allow the final image to not have the test dependencies or test
70
+ # cases.
71
+ ###################################################
72
+ FROM backend-dev AS test
73
+ RUN yarn test
74
+
75
+ ###################################################
76
+ # Stage: final
77
+ #
78
+ # This stage is intended to be the final "production" image. It sets up the
79
+ # backend and copies the built client application from the client-build stage.
80
+ #
81
+ # It pulls the package.json and yarn.lock from the test stage to ensure that
82
+ # the tests run (without this, the test stage would simply be skipped).
83
+ ###################################################
84
+ FROM base AS final
85
+ ENV NODE_ENV=production
86
+ COPY --from=test /usr/local/app/package.json /usr/local/app/yarn.lock ./
87
+ RUN --mount=type=cache,id=yarn,target=/usr/local/share/.cache/yarn \
88
+ yarn install --production --frozen-lockfile
89
+ COPY backend/src ./src
90
+ COPY --from=client-build /usr/local/app/dist ./src/static
91
+ EXPOSE 3000
92
+ CMD ["node", "src/index.js"]
backend/package.json ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "backend",
3
+ "version": "1.0.0",
4
+ "main": "index.js",
5
+ "scripts": {
6
+ "format": "prettier -l --write \"**/*.js\"",
7
+ "format-check": "prettier --check \"**/*.js\"",
8
+ "test": "jest",
9
+ "dev": "nodemon src/index.js"
10
+ },
11
+ "dependencies": {
12
+ "express": "^4.18.2",
13
+ "mysql2": "^3.9.1",
14
+ "sqlite3": "^5.1.7",
15
+ "uuid": "^9.0.1",
16
+ "wait-port": "^1.1.0"
17
+ },
18
+ "resolutions": {
19
+ "@babel/core": "7.23.9"
20
+ },
21
+ "prettier": {
22
+ "trailingComma": "all",
23
+ "tabWidth": 4,
24
+ "useTabs": false,
25
+ "semi": true,
26
+ "singleQuote": true
27
+ },
28
+ "devDependencies": {
29
+ "jest": "^29.7.0",
30
+ "nodemon": "^3.0.3",
31
+ "prettier": "^3.2.4"
32
+ }
33
+ }
backend/spec/persistence/sqlite.spec.js ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const db = require('../../src/persistence/sqlite');
2
+ const fs = require('fs');
3
+ const location = process.env.SQLITE_DB_LOCATION || '/etc/todos/todo.db';
4
+
5
+ const ITEM = {
6
+ id: '7aef3d7c-d301-4846-8358-2a91ec9d6be3',
7
+ name: 'Test',
8
+ completed: false,
9
+ };
10
+
11
+ beforeEach(() => {
12
+ if (fs.existsSync(location)) {
13
+ fs.unlinkSync(location);
14
+ }
15
+ });
16
+
17
+ test('it initializes correctly', async () => {
18
+ await db.init();
19
+ });
20
+
21
+ test('it can store and retrieve items', async () => {
22
+ await db.init();
23
+
24
+ await db.storeItem(ITEM);
25
+
26
+ const items = await db.getItems();
27
+ expect(items.length).toBe(1);
28
+ expect(items[0]).toEqual(ITEM);
29
+ });
30
+
31
+ test('it can update an existing item', async () => {
32
+ await db.init();
33
+
34
+ const initialItems = await db.getItems();
35
+ expect(initialItems.length).toBe(0);
36
+
37
+ await db.storeItem(ITEM);
38
+
39
+ await db.updateItem(
40
+ ITEM.id,
41
+ Object.assign({}, ITEM, { completed: !ITEM.completed }),
42
+ );
43
+
44
+ const items = await db.getItems();
45
+ expect(items.length).toBe(1);
46
+ expect(items[0].completed).toBe(!ITEM.completed);
47
+ });
48
+
49
+ test('it can remove an existing item', async () => {
50
+ await db.init();
51
+ await db.storeItem(ITEM);
52
+
53
+ await db.removeItem(ITEM.id);
54
+
55
+ const items = await db.getItems();
56
+ expect(items.length).toBe(0);
57
+ });
58
+
59
+ test('it can get a single item', async () => {
60
+ await db.init();
61
+ await db.storeItem(ITEM);
62
+
63
+ const item = await db.getItem(ITEM.id);
64
+ expect(item).toEqual(ITEM);
65
+ });
backend/spec/routes/addItem.spec.js ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const db = require('../../src/persistence');
2
+ const addItem = require('../../src/routes/addItem');
3
+ const ITEM = { id: 12345 };
4
+ const { v4: uuid } = require('uuid');
5
+
6
+ jest.mock('uuid', () => ({ v4: jest.fn() }));
7
+
8
+ jest.mock('../../src/persistence', () => ({
9
+ removeItem: jest.fn(),
10
+ storeItem: jest.fn(),
11
+ getItem: jest.fn(),
12
+ }));
13
+
14
+ test('it stores item correctly', async () => {
15
+ const id = 'something-not-a-uuid';
16
+ const name = 'A sample item';
17
+ const req = { body: { name } };
18
+ const res = { send: jest.fn() };
19
+
20
+ uuid.mockReturnValue(id);
21
+
22
+ await addItem(req, res);
23
+
24
+ const expectedItem = { id, name, completed: false };
25
+
26
+ expect(db.storeItem.mock.calls.length).toBe(1);
27
+ expect(db.storeItem.mock.calls[0][0]).toEqual(expectedItem);
28
+ expect(res.send.mock.calls[0].length).toBe(1);
29
+ expect(res.send.mock.calls[0][0]).toEqual(expectedItem);
30
+ });
backend/spec/routes/deleteItem.spec.js ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const db = require('../../src/persistence');
2
+ const deleteItem = require('../../src/routes/deleteItem');
3
+ const ITEM = { id: 12345 };
4
+
5
+ jest.mock('../../src/persistence', () => ({
6
+ removeItem: jest.fn(),
7
+ getItem: jest.fn(),
8
+ }));
9
+
10
+ test('it removes item correctly', async () => {
11
+ const req = { params: { id: 12345 } };
12
+ const res = { sendStatus: jest.fn() };
13
+
14
+ await deleteItem(req, res);
15
+
16
+ expect(db.removeItem.mock.calls.length).toBe(1);
17
+ expect(db.removeItem.mock.calls[0][0]).toBe(req.params.id);
18
+ expect(res.sendStatus.mock.calls[0].length).toBe(1);
19
+ expect(res.sendStatus.mock.calls[0][0]).toBe(200);
20
+ });
backend/spec/routes/getItems.spec.js ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const db = require('../../src/persistence');
2
+ const getItems = require('../../src/routes/getItems');
3
+ const ITEMS = [{ id: 12345 }];
4
+
5
+ jest.mock('../../src/persistence', () => ({
6
+ getItems: jest.fn(),
7
+ }));
8
+
9
+ test('it gets items correctly', async () => {
10
+ const req = {};
11
+ const res = { send: jest.fn() };
12
+ db.getItems.mockReturnValue(Promise.resolve(ITEMS));
13
+
14
+ await getItems(req, res);
15
+
16
+ expect(db.getItems.mock.calls.length).toBe(1);
17
+ expect(res.send.mock.calls[0].length).toBe(1);
18
+ expect(res.send.mock.calls[0][0]).toEqual(ITEMS);
19
+ });
backend/spec/routes/updateItem.spec.js ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const db = require('../../src/persistence');
2
+ const updateItem = require('../../src/routes/updateItem');
3
+ const ITEM = { id: 12345 };
4
+
5
+ jest.mock('../../src/persistence', () => ({
6
+ getItem: jest.fn(),
7
+ updateItem: jest.fn(),
8
+ }));
9
+
10
+ test('it updates items correctly', async () => {
11
+ const req = {
12
+ params: { id: 1234 },
13
+ body: { name: 'New title', completed: false },
14
+ };
15
+ const res = { send: jest.fn() };
16
+
17
+ db.getItem.mockReturnValue(Promise.resolve(ITEM));
18
+
19
+ await updateItem(req, res);
20
+
21
+ expect(db.updateItem.mock.calls.length).toBe(1);
22
+ expect(db.updateItem.mock.calls[0][0]).toBe(req.params.id);
23
+ expect(db.updateItem.mock.calls[0][1]).toEqual({
24
+ name: 'New title',
25
+ completed: false,
26
+ });
27
+
28
+ expect(db.getItem.mock.calls.length).toBe(1);
29
+ expect(db.getItem.mock.calls[0][0]).toBe(req.params.id);
30
+
31
+ expect(res.send.mock.calls[0].length).toBe(1);
32
+ expect(res.send.mock.calls[0][0]).toEqual(ITEM);
33
+ });
backend/src/index.js ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const app = express();
3
+ const db = require('./persistence');
4
+ const getGreeting = require('./routes/getGreeting');
5
+ const getItems = require('./routes/getItems');
6
+ const addItem = require('./routes/addItem');
7
+ const updateItem = require('./routes/updateItem');
8
+ const deleteItem = require('./routes/deleteItem');
9
+
10
+ app.use(express.json());
11
+ app.use(express.static(__dirname + '/static'));
12
+
13
+ app.get('/api/greeting', getGreeting);
14
+ app.get('/api/items', getItems);
15
+ app.post('/api/items', addItem);
16
+ app.put('/api/items/:id', updateItem);
17
+ app.delete('/api/items/:id', deleteItem);
18
+
19
+ db.init()
20
+ .then(() => {
21
+ app.listen(3000, () => console.log('Listening on port 3000'));
22
+ })
23
+ .catch((err) => {
24
+ console.error(err);
25
+ process.exit(1);
26
+ });
27
+
28
+ const gracefulShutdown = () => {
29
+ db.teardown()
30
+ .catch(() => {})
31
+ .then(() => process.exit());
32
+ };
33
+
34
+ process.on('SIGINT', gracefulShutdown);
35
+ process.on('SIGTERM', gracefulShutdown);
36
+ process.on('SIGUSR2', gracefulShutdown); // Sent by nodemon
backend/src/persistence/index.js ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ if (process.env.MYSQL_HOST) module.exports = require('./mysql');
2
+ else module.exports = require('./sqlite');
backend/src/persistence/mysql.js ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const waitPort = require('wait-port');
2
+ const fs = require('fs');
3
+ const mysql = require('mysql2');
4
+
5
+ const {
6
+ MYSQL_HOST: HOST,
7
+ MYSQL_HOST_FILE: HOST_FILE,
8
+ MYSQL_USER: USER,
9
+ MYSQL_USER_FILE: USER_FILE,
10
+ MYSQL_PASSWORD: PASSWORD,
11
+ MYSQL_PASSWORD_FILE: PASSWORD_FILE,
12
+ MYSQL_DB: DB,
13
+ MYSQL_DB_FILE: DB_FILE,
14
+ } = process.env;
15
+
16
+ let pool;
17
+
18
+ async function init() {
19
+ const host = HOST_FILE ? fs.readFileSync(HOST_FILE) : HOST;
20
+ const user = USER_FILE ? fs.readFileSync(USER_FILE) : USER;
21
+ const password = PASSWORD_FILE ? fs.readFileSync(PASSWORD_FILE) : PASSWORD;
22
+ const database = DB_FILE ? fs.readFileSync(DB_FILE) : DB;
23
+
24
+ await waitPort({
25
+ host,
26
+ port: 3306,
27
+ timeout: 10000,
28
+ waitForDns: true,
29
+ });
30
+
31
+ pool = mysql.createPool({
32
+ connectionLimit: 5,
33
+ host,
34
+ user,
35
+ password,
36
+ database,
37
+ charset: 'utf8mb4',
38
+ });
39
+
40
+ return new Promise((acc, rej) => {
41
+ pool.query(
42
+ 'CREATE TABLE IF NOT EXISTS todo_items (id varchar(36), name varchar(255), completed boolean) DEFAULT CHARSET utf8mb4',
43
+ (err) => {
44
+ if (err) return rej(err);
45
+
46
+ console.log(`Connected to mysql db at host ${HOST}`);
47
+ acc();
48
+ },
49
+ );
50
+ });
51
+ }
52
+
53
+ async function teardown() {
54
+ return new Promise((acc, rej) => {
55
+ pool.end((err) => {
56
+ if (err) rej(err);
57
+ else acc();
58
+ });
59
+ });
60
+ }
61
+
62
+ async function getItems() {
63
+ return new Promise((acc, rej) => {
64
+ pool.query('SELECT * FROM todo_items', (err, rows) => {
65
+ if (err) return rej(err);
66
+ acc(
67
+ rows.map((item) =>
68
+ Object.assign({}, item, {
69
+ completed: item.completed === 1,
70
+ }),
71
+ ),
72
+ );
73
+ });
74
+ });
75
+ }
76
+
77
+ async function getItem(id) {
78
+ return new Promise((acc, rej) => {
79
+ pool.query('SELECT * FROM todo_items WHERE id=?', [id], (err, rows) => {
80
+ if (err) return rej(err);
81
+ acc(
82
+ rows.map((item) =>
83
+ Object.assign({}, item, {
84
+ completed: item.completed === 1,
85
+ }),
86
+ )[0],
87
+ );
88
+ });
89
+ });
90
+ }
91
+
92
+ async function storeItem(item) {
93
+ return new Promise((acc, rej) => {
94
+ pool.query(
95
+ 'INSERT INTO todo_items (id, name, completed) VALUES (?, ?, ?)',
96
+ [item.id, item.name, item.completed ? 1 : 0],
97
+ (err) => {
98
+ if (err) return rej(err);
99
+ acc();
100
+ },
101
+ );
102
+ });
103
+ }
104
+
105
+ async function updateItem(id, item) {
106
+ return new Promise((acc, rej) => {
107
+ pool.query(
108
+ 'UPDATE todo_items SET name=?, completed=? WHERE id=?',
109
+ [item.name, item.completed ? 1 : 0, id],
110
+ (err) => {
111
+ if (err) return rej(err);
112
+ acc();
113
+ },
114
+ );
115
+ });
116
+ }
117
+
118
+ async function removeItem(id) {
119
+ return new Promise((acc, rej) => {
120
+ pool.query('DELETE FROM todo_items WHERE id = ?', [id], (err) => {
121
+ if (err) return rej(err);
122
+ acc();
123
+ });
124
+ });
125
+ }
126
+
127
+ module.exports = {
128
+ init,
129
+ teardown,
130
+ getItems,
131
+ getItem,
132
+ storeItem,
133
+ updateItem,
134
+ removeItem,
135
+ };
backend/src/persistence/sqlite.js ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const sqlite3 = require('sqlite3').verbose();
2
+ const fs = require('fs');
3
+ const location = process.env.SQLITE_DB_LOCATION || '/etc/todos/todo.db';
4
+
5
+ let db, dbAll, dbRun;
6
+
7
+ function init() {
8
+ const dirName = require('path').dirname(location);
9
+ if (!fs.existsSync(dirName)) {
10
+ fs.mkdirSync(dirName, { recursive: true });
11
+ }
12
+
13
+ return new Promise((acc, rej) => {
14
+ db = new sqlite3.Database(location, (err) => {
15
+ if (err) return rej(err);
16
+
17
+ if (process.env.NODE_ENV !== 'test')
18
+ console.log(`Using sqlite database at ${location}`);
19
+
20
+ db.run(
21
+ 'CREATE TABLE IF NOT EXISTS todo_items (id varchar(36), name varchar(255), completed boolean)',
22
+ (err, result) => {
23
+ if (err) return rej(err);
24
+ acc();
25
+ },
26
+ );
27
+ });
28
+ });
29
+ }
30
+
31
+ async function teardown() {
32
+ return new Promise((acc, rej) => {
33
+ db.close((err) => {
34
+ if (err) rej(err);
35
+ else acc();
36
+ });
37
+ });
38
+ }
39
+
40
+ async function getItems() {
41
+ return new Promise((acc, rej) => {
42
+ db.all('SELECT * FROM todo_items', (err, rows) => {
43
+ if (err) return rej(err);
44
+ acc(
45
+ rows.map((item) =>
46
+ Object.assign({}, item, {
47
+ completed: item.completed === 1,
48
+ }),
49
+ ),
50
+ );
51
+ });
52
+ });
53
+ }
54
+
55
+ async function getItem(id) {
56
+ return new Promise((acc, rej) => {
57
+ db.all('SELECT * FROM todo_items WHERE id=?', [id], (err, rows) => {
58
+ if (err) return rej(err);
59
+ acc(
60
+ rows.map((item) =>
61
+ Object.assign({}, item, {
62
+ completed: item.completed === 1,
63
+ }),
64
+ )[0],
65
+ );
66
+ });
67
+ });
68
+ }
69
+
70
+ async function storeItem(item) {
71
+ return new Promise((acc, rej) => {
72
+ db.run(
73
+ 'INSERT INTO todo_items (id, name, completed) VALUES (?, ?, ?)',
74
+ [item.id, item.name, item.completed ? 1 : 0],
75
+ (err) => {
76
+ if (err) return rej(err);
77
+ acc();
78
+ },
79
+ );
80
+ });
81
+ }
82
+
83
+ async function updateItem(id, item) {
84
+ return new Promise((acc, rej) => {
85
+ db.run(
86
+ 'UPDATE todo_items SET name=?, completed=? WHERE id = ?',
87
+ [item.name, item.completed ? 1 : 0, id],
88
+ (err) => {
89
+ if (err) return rej(err);
90
+ acc();
91
+ },
92
+ );
93
+ });
94
+ }
95
+
96
+ async function removeItem(id) {
97
+ return new Promise((acc, rej) => {
98
+ db.run('DELETE FROM todo_items WHERE id = ?', [id], (err) => {
99
+ if (err) return rej(err);
100
+ acc();
101
+ });
102
+ });
103
+ }
104
+
105
+ module.exports = {
106
+ init,
107
+ teardown,
108
+ getItems,
109
+ getItem,
110
+ storeItem,
111
+ updateItem,
112
+ removeItem,
113
+ };
backend/src/routes/addItem.js ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const db = require('../persistence');
2
+ const { v4: uuid } = require('uuid');
3
+
4
+ module.exports = async (req, res) => {
5
+ const item = {
6
+ id: uuid(),
7
+ name: req.body.name,
8
+ completed: false,
9
+ };
10
+
11
+ await db.storeItem(item);
12
+ res.send(item);
13
+ };
backend/src/routes/deleteItem.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ const db = require('../persistence');
2
+
3
+ module.exports = async (req, res) => {
4
+ await db.removeItem(req.params.id);
5
+ res.sendStatus(200);
6
+ };
backend/src/routes/getGreeting.js ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ const GREETING = 'Hello world!';
2
+
3
+ module.exports = async (req, res) => {
4
+ res.send({
5
+ greeting: GREETING,
6
+ });
7
+ };
backend/src/routes/getItems.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ const db = require('../persistence');
2
+
3
+ module.exports = async (req, res) => {
4
+ const items = await db.getItems();
5
+ res.send(items);
6
+ };
backend/src/routes/updateItem.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ const db = require('../persistence');
2
+
3
+ module.exports = async (req, res) => {
4
+ await db.updateItem(req.params.id, {
5
+ name: req.body.name,
6
+ completed: req.body.completed,
7
+ });
8
+ const item = await db.getItem(req.params.id);
9
+ res.send(item);
10
+ };
backend/src/static/.gitkeep ADDED
File without changes
backend/yarn.lock ADDED
The diff for this file is too large to render. See raw diff
 
client/.eslintrc.cjs ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ module.exports = {
2
+ root: true,
3
+ env: { browser: true, es2020: true },
4
+ extends: [
5
+ 'eslint:recommended',
6
+ 'plugin:react/recommended',
7
+ 'plugin:react/jsx-runtime',
8
+ 'plugin:react-hooks/recommended',
9
+ ],
10
+ ignorePatterns: ['dist', '.eslintrc.cjs'],
11
+ parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
12
+ settings: { react: { version: '18.2' } },
13
+ plugins: ['react-refresh'],
14
+ rules: {
15
+ 'react-refresh/only-export-components': [
16
+ 'warn',
17
+ { allowConstantExport: true },
18
+ ],
19
+ },
20
+ };
client/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
client/index.html ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <link
8
+ href="https://fonts.googleapis.com/css?family=Lato&display=swap"
9
+ rel="stylesheet"
10
+ />
11
+ <title>Todo App</title>
12
+ </head>
13
+ <body>
14
+ <div id="root"></div>
15
+ <script type="module" src="/src/main.jsx"></script>
16
+ </body>
17
+ </html>
client/package.json ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "client",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite --host=0.0.0.0",
8
+ "build": "vite build",
9
+ "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
10
+ "preview": "vite preview",
11
+ "format": "prettier --write \"**/*.jsx\"",
12
+ "format-check": "prettier --check \"**/*.js\""
13
+ },
14
+ "dependencies": {
15
+ "@fortawesome/fontawesome-free-regular": "^5.0.13",
16
+ "@fortawesome/fontawesome-svg-core": "^6.5.1",
17
+ "@fortawesome/free-solid-svg-icons": "^6.5.1",
18
+ "@fortawesome/react-fontawesome": "^0.2.0",
19
+ "bootstrap": "^5.3.2",
20
+ "react": "^18.2.0",
21
+ "react-bootstrap": "^2.10.0",
22
+ "react-dom": "^18.2.0",
23
+ "sass": "^1.70.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/react": "^18.2.43",
27
+ "@types/react-dom": "^18.2.17",
28
+ "@vitejs/plugin-react": "^4.2.1",
29
+ "eslint": "^8.55.0",
30
+ "eslint-plugin-react": "^7.33.2",
31
+ "eslint-plugin-react-hooks": "^4.6.0",
32
+ "eslint-plugin-react-refresh": "^0.4.5",
33
+ "prettier": "^3.2.4",
34
+ "vite": "^5.0.8"
35
+ },
36
+ "prettier": {
37
+ "trailingComma": "all",
38
+ "tabWidth": 4,
39
+ "useTabs": false,
40
+ "semi": true,
41
+ "singleQuote": true
42
+ }
43
+ }
client/public/vite.svg ADDED
client/src/App.jsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Col from 'react-bootstrap/Col';
2
+ import Container from 'react-bootstrap/Container';
3
+ import Row from 'react-bootstrap/Row';
4
+ import { TodoListCard } from './components/TodoListCard';
5
+ import { Greeting } from './components/Greeting';
6
+
7
+ function App() {
8
+ return (
9
+ <Container>
10
+ <Row>
11
+ <Col md={{ offset: 3, span: 6 }}>
12
+ <Greeting />
13
+ <TodoListCard />
14
+ </Col>
15
+ </Row>
16
+ </Container>
17
+ );
18
+ }
19
+
20
+ export default App;
client/src/components/AddNewItemForm.jsx ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import Button from 'react-bootstrap/Button';
4
+ import Form from 'react-bootstrap/Form';
5
+ import InputGroup from 'react-bootstrap/InputGroup';
6
+
7
+ export function AddItemForm({ onNewItem }) {
8
+ const [newItem, setNewItem] = useState('');
9
+ const [submitting, setSubmitting] = useState(false);
10
+
11
+ const submitNewItem = (e) => {
12
+ e.preventDefault();
13
+ setSubmitting(true);
14
+
15
+ const options = {
16
+ method: 'POST',
17
+ body: JSON.stringify({ name: newItem }),
18
+ headers: { 'Content-Type': 'application/json' },
19
+ };
20
+
21
+ fetch('/api/items', options)
22
+ .then((r) => r.json())
23
+ .then((item) => {
24
+ onNewItem(item);
25
+ setSubmitting(false);
26
+ setNewItem('');
27
+ });
28
+ };
29
+
30
+ return (
31
+ <Form onSubmit={submitNewItem}>
32
+ <InputGroup className="mb-3">
33
+ <Form.Control
34
+ value={newItem}
35
+ onChange={(e) => setNewItem(e.target.value)}
36
+ type="text"
37
+ placeholder="New Item"
38
+ aria-label="New item"
39
+ />
40
+ <Button
41
+ type="submit"
42
+ variant="success"
43
+ disabled={!newItem.length}
44
+ className={submitting ? 'disabled' : ''}
45
+ >
46
+ {submitting ? 'Adding...' : 'Add Item'}
47
+ </Button>
48
+ </InputGroup>
49
+ </Form>
50
+ );
51
+ }
52
+
53
+ AddItemForm.propTypes = {
54
+ onNewItem: PropTypes.func,
55
+ };
client/src/components/Greeting.jsx ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from 'react';
2
+
3
+ export function Greeting() {
4
+ const [greeting, setGreeting] = useState(null);
5
+
6
+ useEffect(() => {
7
+ fetch('/api/greeting')
8
+ .then((res) => res.json())
9
+ .then((data) => setGreeting(data.greeting));
10
+ }, [setGreeting]);
11
+
12
+ if (!greeting) return null;
13
+
14
+ return <h1 className="text-center mb-5">{greeting}</h1>;
15
+ }
client/src/components/ItemDisplay.jsx ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import PropTypes from 'prop-types';
2
+ import Container from 'react-bootstrap/Container';
3
+ import Row from 'react-bootstrap/Row';
4
+ import Col from 'react-bootstrap/Col';
5
+ import Button from 'react-bootstrap/Button';
6
+ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
7
+ import { faTrash } from '@fortawesome/free-solid-svg-icons/faTrash';
8
+ import faCheckSquare from '@fortawesome/fontawesome-free-regular/faCheckSquare';
9
+ import faSquare from '@fortawesome/fontawesome-free-regular/faSquare';
10
+ import './ItemDisplay.scss';
11
+
12
+ export function ItemDisplay({ item, onItemUpdate, onItemRemoval }) {
13
+ const toggleCompletion = () => {
14
+ fetch(`/api/items/${item.id}`, {
15
+ method: 'PUT',
16
+ body: JSON.stringify({
17
+ name: item.name,
18
+ completed: !item.completed,
19
+ }),
20
+ headers: { 'Content-Type': 'application/json' },
21
+ })
22
+ .then((r) => r.json())
23
+ .then(onItemUpdate);
24
+ };
25
+
26
+ const removeItem = () => {
27
+ fetch(`/api/items/${item.id}`, { method: 'DELETE' }).then(() =>
28
+ onItemRemoval(item),
29
+ );
30
+ };
31
+
32
+ return (
33
+ <Container fluid className={`item ${item.completed && 'completed'}`}>
34
+ <Row>
35
+ <Col xs={2} className="text-center">
36
+ <Button
37
+ className="toggles"
38
+ size="sm"
39
+ variant="link"
40
+ onClick={toggleCompletion}
41
+ aria-label={
42
+ item.completed
43
+ ? 'Mark item as incomplete'
44
+ : 'Mark item as complete'
45
+ }
46
+ >
47
+ <FontAwesomeIcon
48
+ icon={item.completed ? faCheckSquare : faSquare}
49
+ />
50
+ <i
51
+ className={`far ${
52
+ item.completed ? 'fa-check-square' : 'fa-square'
53
+ }`}
54
+ />
55
+ </Button>
56
+ </Col>
57
+ <Col xs={8} className="name">
58
+ {item.name}
59
+ </Col>
60
+ <Col xs={2} className="text-center remove">
61
+ <Button
62
+ size="sm"
63
+ variant="link"
64
+ onClick={removeItem}
65
+ aria-label="Remove Item"
66
+ >
67
+ <FontAwesomeIcon
68
+ icon={faTrash}
69
+ className="text-danger"
70
+ />
71
+ </Button>
72
+ </Col>
73
+ </Row>
74
+ </Container>
75
+ );
76
+ }
77
+
78
+ ItemDisplay.propTypes = {
79
+ item: PropTypes.shape({
80
+ id: PropTypes.string,
81
+ name: PropTypes.string,
82
+ completed: PropTypes.bool,
83
+ }),
84
+ onItemUpdate: PropTypes.func,
85
+ onItemRemoval: PropTypes.func,
86
+ };
client/src/components/ItemDisplay.scss ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .item {
2
+ background-color: white;
3
+ padding: 15px;
4
+ margin-bottom: 15px;
5
+ border: transparent;
6
+ border-radius: 5px;
7
+ box-shadow: 0 0 1em #ccc;
8
+ transition: all 0.2s ease-in-out;
9
+
10
+ &:hover {
11
+ box-shadow: 0 0 1em #aaa;
12
+ }
13
+
14
+ &.completed {
15
+ text-decoration: line-through;
16
+ }
17
+ }
18
+
19
+ .toggles {
20
+ color: black;
21
+ }
22
+
23
+ .name {
24
+ padding-top: 3px;
25
+ }
26
+
27
+ .remove {
28
+ padding-left: 0;
29
+ }
30
+
31
+ button:focus {
32
+ border: 1px solid #333;
33
+ }
client/src/components/TodoListCard.jsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { AddItemForm } from './AddNewItemForm';
3
+ import { ItemDisplay } from './ItemDisplay';
4
+
5
+ export function TodoListCard() {
6
+ const [items, setItems] = useState(null);
7
+
8
+ useEffect(() => {
9
+ fetch('/api/items')
10
+ .then((r) => r.json())
11
+ .then(setItems);
12
+ }, []);
13
+
14
+ const onNewItem = useCallback(
15
+ (newItem) => {
16
+ setItems([...items, newItem]);
17
+ },
18
+ [items],
19
+ );
20
+
21
+ const onItemUpdate = useCallback(
22
+ (item) => {
23
+ const index = items.findIndex((i) => i.id === item.id);
24
+ setItems([
25
+ ...items.slice(0, index),
26
+ item,
27
+ ...items.slice(index + 1),
28
+ ]);
29
+ },
30
+ [items],
31
+ );
32
+
33
+ const onItemRemoval = useCallback(
34
+ (item) => {
35
+ const index = items.findIndex((i) => i.id === item.id);
36
+ setItems([...items.slice(0, index), ...items.slice(index + 1)]);
37
+ },
38
+ [items],
39
+ );
40
+
41
+ if (items === null) return 'Loading...';
42
+
43
+ return (
44
+ <>
45
+ <AddItemForm onNewItem={onNewItem} />
46
+ {items.length === 0 && (
47
+ <p className="text-center">No items yet! Add one above!</p>
48
+ )}
49
+ {items.map((item) => (
50
+ <ItemDisplay
51
+ key={item.id}
52
+ item={item}
53
+ onItemUpdate={onItemUpdate}
54
+ onItemRemoval={onItemRemoval}
55
+ />
56
+ ))}
57
+ </>
58
+ );
59
+ }
client/src/index.scss ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ @import 'bootstrap/scss/bootstrap';
2
+
3
+ body {
4
+ background-color: #f4f4f4;
5
+ margin-top: 50px;
6
+ font-family: 'Lato';
7
+ }
client/src/main.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import App from './App.jsx';
4
+ import './index.scss';
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ );
client/vite.config.js ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+
4
+ // https://vitejs.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ });
client/yarn.lock ADDED
The diff for this file is too large to render. See raw diff
 
compose.yml ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ###################################################
2
+ # This Compose file provides the development environment for the todo app.
3
+ #
4
+ # Seeing the final version of the application bundles the frontend with the
5
+ # backend, we are able to "simulate" that by using a proxy to route requests
6
+ # to the appropriate service. All requests to /api will be routed to the
7
+ # backend while all other requests will be sent to the client service. While
8
+ # there is some overlap in the routing rules, the proxy determines the service
9
+ # based on the most specific rule.
10
+ #
11
+ # To support easier debugging and troubleshooting, phpMyAdmin is also included
12
+ # to provide a web interface to the MySQL database.
13
+ ###################################################
14
+
15
+ ###################################################
16
+ # Services
17
+ #
18
+ # The services define the individual components of our application stack.
19
+ # For each service, a separate container will be launched.
20
+ ###################################################
21
+ services:
22
+
23
+ ###################################################
24
+ # Service: proxy
25
+ #
26
+ # This service is a reverse proxy that will route requests to the appropriate
27
+ # service. Think of it like a HTTP router or a load balancer. It simply
28
+ # forwards requests and allows us to simulate the final version of the
29
+ # application where the frontend and backend are bundled together. We can
30
+ # also use it to route requests to phpMyAdmin, which won't be accessible at
31
+ # localhost, but at db.localhost.
32
+ #
33
+ # The image for this service comes directly from Docker Hub and is a Docker
34
+ # Official Image. Since Traefik can be configured in a variety of ways, we
35
+ # configure it here to watch the Docker events for new containers and to use
36
+ # their labels for configuration. That's why the Docker socket is mounted.
37
+ #
38
+ # We also expose port 80 to connect to the proxy from the host machine.
39
+ ###################################################
40
+ proxy:
41
+ image: traefik:v2.11
42
+ command: --providers.docker
43
+ ports:
44
+ - 80:80
45
+ volumes:
46
+ - /var/run/docker.sock:/var/run/docker.sock
47
+
48
+ ###################################################
49
+ # Service: backend
50
+ #
51
+ # This service is the Node.js server that provides the API for the app.
52
+ # When the container starts, it will use the image that results
53
+ # from building the Dockerfile, targeting the backend-dev stage.
54
+ #
55
+ # The Compose Watch configuration is used to automatically sync the code
56
+ # from the host machine to the container. This allows the server to be
57
+ # automatically reloaded when code changes are made.
58
+ #
59
+ # The environment variables configure the application to connect to the
60
+ # database, which is also configured in this Compose file. We obviously
61
+ # wouldn't hard-code these values in a production environment. But, in
62
+ # dev, these values are fine.
63
+ #
64
+ # Finally, the labels are used to configure Traefik (the reverse proxy) with
65
+ # the appropriate routing rules. In this case, all requests to localhost/api/*
66
+ # will be forwarded to this service's port 3000.
67
+ ###################################################
68
+ backend:
69
+ build:
70
+ context: ./
71
+ target: backend-dev
72
+ environment:
73
+ MYSQL_HOST: mysql
74
+ MYSQL_USER: root
75
+ MYSQL_PASSWORD: secret
76
+ MYSQL_DB: todos
77
+ develop:
78
+ watch:
79
+ - path: ./backend/src
80
+ action: sync
81
+ target: /usr/local/app/src
82
+ - path: ./backend/package.json
83
+ action: rebuild
84
+ labels:
85
+ traefik.http.routers.backend.rule: Host(`localhost`) && PathPrefix(`/api`)
86
+ traefik.http.services.backend.loadbalancer.server.port: 3000
87
+
88
+ ###################################################
89
+ # Service: client
90
+ #
91
+ # The client service is the React app that provides the frontend for the app.
92
+ # When the container starts, it will use the image that results from building
93
+ # the Dockerfile, targeting the dev stage.
94
+ #
95
+ # The Compose Watch configuration is used to automatically sync the code from
96
+ # the host machine to the container. This allows the client to be automatically
97
+ # reloaded when code changes are made.
98
+ #
99
+ # The labels are used to configure Traefik (the reverse proxy) with the
100
+ # appropriate routing rules. In this case, all requests to localhost will be
101
+ # forwarded to this service's port 5173.
102
+ ###################################################
103
+ client:
104
+ build:
105
+ context: ./
106
+ target: client-dev
107
+ develop:
108
+ watch:
109
+ - path: ./client/src
110
+ action: sync
111
+ target: /usr/local/app/src
112
+ - path: ./client/package.json
113
+ action: rebuild
114
+ labels:
115
+ traefik.http.routers.client.rule: Host(`localhost`)
116
+ traefik.http.services.client.loadbalancer.server.port: 5173
117
+
118
+
119
+ ###################################################
120
+ # Service: mysql
121
+ #
122
+ # The MySQL service is used to provide the database for the application.
123
+ # The image for this service comes directly from Docker Hub and is a Docker
124
+ # Official Image.
125
+
126
+ # The data is persisted in a volume named todo-mysql-data. Using a volume
127
+ # allows us to take down the services without losing the data. When we start
128
+ # the services again, the data will still be there (assuming we didn't delete
129
+ # the volume, of course!).
130
+ #
131
+ # The environment variables configure the root password and the name of the
132
+ # database to create. Since these are used only for local development, it's
133
+ # ok to hard-code them here.
134
+ ###################################################
135
+ mysql:
136
+ image: mysql:8.0
137
+ volumes:
138
+ - todo-mysql-data:/var/lib/mysql
139
+ environment:
140
+ MYSQL_ROOT_PASSWORD: secret
141
+ MYSQL_DATABASE: todos
142
+
143
+ ###################################################
144
+ # Service: phpmyadmin
145
+ #
146
+ # This service provides a web interface to the MySQL database. It's useful
147
+ # for debugging and troubleshooting data, schemas, and more. The image for
148
+ # this service comes directly from Docker Hub and is a Docker Official Image.
149
+ #
150
+ # The environment variables configure the connection to the database and
151
+ # provide the default credentials, letting us immediately open the interface
152
+ # without needing to log in.
153
+ #
154
+ # The labels are used to configure Traefik (the reverse proxy) with the
155
+ # routing rules. In this case, all requests to db.localhost will be forwarded
156
+ # to this service's port 80.
157
+ ###################################################
158
+ phpmyadmin:
159
+ image: phpmyadmin
160
+ environment:
161
+ PMA_HOST: mysql
162
+ PMA_USER: root
163
+ PMA_PASSWORD: secret
164
+ labels:
165
+ traefik.http.routers.phpmyadmin.rule: Host(`db.localhost`)
166
+ traefik.http.services.phpmyadmin.loadbalancer.server.port: 80
167
+
168
+ ###################################################
169
+ # Volumes
170
+ #
171
+ # For this application stack, we only have one volume. It's used to persist the
172
+ # data for the MySQL service. We are only going to use the default values,
173
+ # hence the lack of any configuration for the volume.
174
+ ###################################################
175
+ volumes:
176
+ todo-mysql-data: