├── .dockerignore ├── backend ├── src │ ├── static │ │ └── .gitkeep │ ├── persistence │ │ ├── index.js │ │ ├── sqlite.js │ │ └── mysql.js │ ├── routes │ │ ├── getGreeting.js │ │ ├── getItems.js │ │ ├── deleteItem.js │ │ ├── updateItem.js │ │ └── addItem.js │ └── index.js ├── spec │ ├── routes │ │ ├── getItems.spec.js │ │ ├── deleteItem.spec.js │ │ ├── addItem.spec.js │ │ └── updateItem.spec.js │ └── persistence │ │ └── sqlite.spec.js └── package.json ├── client ├── src │ ├── index.scss │ ├── main.jsx │ ├── components │ │ ├── Greeting.jsx │ │ ├── ItemDisplay.scss │ │ ├── TodoListCard.jsx │ │ ├── AddNewItemForm.jsx │ │ └── ItemDisplay.jsx │ └── App.jsx ├── vite.config.js ├── .gitignore ├── index.html ├── .eslintrc.cjs ├── package.json └── public │ └── vite.svg ├── README.md ├── .gitignore ├── Dockerfile ├── compose.yaml └── LICENSE /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /backend/src/static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/src/persistence/index.js: -------------------------------------------------------------------------------- 1 | if (process.env.MYSQL_HOST) module.exports = require('./mysql'); 2 | else module.exports = require('./sqlite'); 3 | -------------------------------------------------------------------------------- /client/src/index.scss: -------------------------------------------------------------------------------- 1 | @import 'bootstrap/scss/bootstrap'; 2 | 3 | body { 4 | background-color: #f4f4f4; 5 | margin-top: 50px; 6 | font-family: 'Lato'; 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/routes/getGreeting.js: -------------------------------------------------------------------------------- 1 | const GREETING = 'Hello world!'; 2 | 3 | module.exports = async (req, res) => { 4 | res.send({ 5 | greeting: GREETING, 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /backend/src/routes/getItems.js: -------------------------------------------------------------------------------- 1 | const db = require('../persistence'); 2 | 3 | module.exports = async (req, res) => { 4 | const items = await db.getItems(); 5 | res.send(items); 6 | }; 7 | -------------------------------------------------------------------------------- /backend/src/routes/deleteItem.js: -------------------------------------------------------------------------------- 1 | const db = require('../persistence'); 2 | 3 | module.exports = async (req, res) => { 4 | await db.removeItem(req.params.id); 5 | res.sendStatus(200); 6 | }; 7 | -------------------------------------------------------------------------------- /client/vite.config.js: -------------------------------------------------------------------------------- 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 | }); 8 | -------------------------------------------------------------------------------- /client/src/main.jsx: -------------------------------------------------------------------------------- 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 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /backend/src/routes/updateItem.js: -------------------------------------------------------------------------------- 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 | }; 11 | -------------------------------------------------------------------------------- /backend/src/routes/addItem.js: -------------------------------------------------------------------------------- 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 | }; 14 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 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? 25 | -------------------------------------------------------------------------------- /client/src/components/Greeting.jsx: -------------------------------------------------------------------------------- 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

{greeting}

; 15 | } 16 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | Todo App 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /client/src/App.jsx: -------------------------------------------------------------------------------- 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 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | 20 | export default App; 21 | -------------------------------------------------------------------------------- /client/src/components/ItemDisplay.scss: -------------------------------------------------------------------------------- 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 | } 34 | -------------------------------------------------------------------------------- /backend/spec/routes/getItems.spec.js: -------------------------------------------------------------------------------- 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 | }); 20 | -------------------------------------------------------------------------------- /client/.eslintrc.cjs: -------------------------------------------------------------------------------- 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 | }; 21 | -------------------------------------------------------------------------------- /backend/spec/routes/deleteItem.spec.js: -------------------------------------------------------------------------------- 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 | }); 21 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 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": "^5.1.0", 13 | "mysql2": "^3.14.1", 14 | "sqlite3": "^5.1.7", 15 | "uuid": "^11.1.0", 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 | } 34 | -------------------------------------------------------------------------------- /backend/spec/routes/addItem.spec.js: -------------------------------------------------------------------------------- 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 | }); 31 | -------------------------------------------------------------------------------- /backend/spec/routes/updateItem.spec.js: -------------------------------------------------------------------------------- 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 | }); 34 | -------------------------------------------------------------------------------- /backend/src/index.js: -------------------------------------------------------------------------------- 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 37 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 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.2", 19 | "bootstrap": "^5.3.6", 20 | "react": "^19.1.0", 21 | "react-bootstrap": "^2.10.10", 22 | "react-dom": "^19.1.0", 23 | "sass": "^1.70.0" 24 | }, 25 | "devDependencies": { 26 | "@types/react": "^19.1.6", 27 | "@types/react-dom": "^19.1.6", 28 | "@vitejs/plugin-react": "^4.5.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": "^6.3.5" 35 | }, 36 | "prettier": { 37 | "trailingComma": "all", 38 | "tabWidth": 4, 39 | "useTabs": false, 40 | "semi": true, 41 | "singleQuote": true 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /client/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/spec/persistence/sqlite.spec.js: -------------------------------------------------------------------------------- 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 | }); 66 | -------------------------------------------------------------------------------- /client/src/components/TodoListCard.jsx: -------------------------------------------------------------------------------- 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 | 46 | {items.length === 0 && ( 47 |

No items yet! Add one above!

48 | )} 49 | {items.map((item) => ( 50 | 56 | ))} 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /client/src/components/AddNewItemForm.jsx: -------------------------------------------------------------------------------- 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 |
32 | 33 | setNewItem(e.target.value)} 36 | type="text" 37 | placeholder="New Item" 38 | aria-label="New item" 39 | /> 40 | 48 | 49 |
50 | ); 51 | } 52 | 53 | AddItemForm.propTypes = { 54 | onNewItem: PropTypes.func, 55 | }; 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Getting Started Todo App 2 | 3 | This project provides a sample todo list application. It demonstrates all of 4 | the current Docker best practices, ranging from the Compose file, to the 5 | Dockerfile, to CI (using GitHub Actions), and running tests. It's intended to 6 | be well-documented to ensure anyone can come in and easily learn. 7 | 8 | ## Application architecture 9 | 10 | ![image](https://github.com/docker/getting-started-todo-app/assets/313480/c128b8e4-366f-4b6f-ad73-08e6652b7c4d) 11 | 12 | 13 | This sample application is a simple React frontend that receives data from a 14 | Node.js backend. 15 | 16 | When the application is packaged and shipped, the frontend is compiled into 17 | static HTML, CSS, and JS and then bundled with the backend where it is then 18 | served as static assets. So no... there is no server-side rendering going on 19 | with this sample app. 20 | 21 | During development, since the backend and frontend need different dev tools, 22 | they are split into two separate services. This allows [Vite](https://vitejs.dev/) 23 | to manage the React app while [nodemon](https://nodemon.io/) works with the 24 | backend. With containers, it's easy to separate the development needs! 25 | 26 | ## Development 27 | 28 | To spin up the project, simply install Docker Desktop and then run the following 29 | commands: 30 | 31 | ``` 32 | git clone https://github.com/docker/getting-started-todo-app 33 | cd getting-started-todo-app 34 | docker compose up --watch 35 | ``` 36 | 37 | You'll see several container images get downloaded from Docker Hub and, after a 38 | moment, the application will be up and running! No need to install or configure 39 | anything on your machine! 40 | 41 | Simply open to [http://localhost](http://localhost) to see the app up and running! 42 | 43 | Any changes made to either the backend or frontend should be seen immediately 44 | without needing to rebuild or restart the containers. 45 | 46 | To help with the database, the development stack also includes phpMyAdmin, which 47 | can be accessed at [http://db.localhost](http://db.localhost) (most browsers will 48 | resolve `*.localhost` correctly, so no hosts file changes should be required). 49 | 50 | ### Tearing it down 51 | 52 | When you're done, simply remove the containers by running the following command: 53 | 54 | ``` 55 | docker compose down 56 | ``` 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 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.* -------------------------------------------------------------------------------- /client/src/components/ItemDisplay.jsx: -------------------------------------------------------------------------------- 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 | 34 | 35 | 36 | 56 | 57 | 58 | {item.name} 59 | 60 | 61 | 72 | 73 | 74 | 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 | }; 87 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 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:22 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/package-lock.json ./ 20 | RUN npm install 21 | COPY client/.eslintrc.cjs client/index.html client/vite.config.js ./ 22 | COPY client/public ./public 23 | COPY client/src ./src 24 | 25 | ################################################### 26 | # Stage: client-dev 27 | # 28 | # This stage is used for development of the client application. It sets 29 | # the default command to start the Vite development server. 30 | ################################################### 31 | FROM client-base AS client-dev 32 | CMD ["npm", "run", "dev"] 33 | 34 | ################################################### 35 | # Stage: client-build 36 | # 37 | # This stage builds the client application, producing static HTML, CSS, and 38 | # JS files that can be served by the backend. 39 | ################################################### 40 | FROM client-base AS client-build 41 | RUN npm run build 42 | 43 | 44 | 45 | 46 | ################################################### 47 | ################ BACKEND STAGES ################# 48 | ################################################### 49 | 50 | ################################################### 51 | # Stage: backend-base 52 | # 53 | # This stage is used as the base for the backend-dev and test stages, since 54 | # there are common steps needed for each. 55 | ################################################### 56 | FROM base AS backend-dev 57 | COPY backend/package.json backend/package-lock.json ./ 58 | RUN npm install 59 | COPY backend/spec ./spec 60 | COPY backend/src ./src 61 | CMD ["npm", "run", "dev"] 62 | 63 | ################################################### 64 | # Stage: test 65 | # 66 | # This stage runs the tests on the backend. This is split into a separate 67 | # stage to allow the final image to not have the test dependencies or test 68 | # cases. 69 | ################################################### 70 | FROM backend-dev AS test 71 | RUN npm run test 72 | 73 | ################################################### 74 | # Stage: final 75 | # 76 | # This stage is intended to be the final "production" image. It sets up the 77 | # backend and copies the built client application from the client-build stage. 78 | # 79 | # It pulls the package.json and package-lock.json from the test stage to ensure that 80 | # the tests run (without this, the test stage would simply be skipped). 81 | ################################################### 82 | FROM base AS final 83 | ENV NODE_ENV=production 84 | COPY --from=test /usr/local/app/package.json /usr/local/app/package-lock.json ./ 85 | RUN npm ci --production && \ 86 | npm cache clean --force 87 | COPY backend/src ./src 88 | COPY --from=client-build /usr/local/app/dist ./src/static 89 | EXPOSE 3000 90 | CMD ["node", "src/index.js"] -------------------------------------------------------------------------------- /backend/src/persistence/sqlite.js: -------------------------------------------------------------------------------- 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 | }; 114 | -------------------------------------------------------------------------------- /backend/src/persistence/mysql.js: -------------------------------------------------------------------------------- 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 | }; 136 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 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:v3.6 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 | depends_on: 78 | mysql: 79 | condition: service_healthy 80 | develop: 81 | watch: 82 | - path: ./backend/src 83 | action: sync 84 | target: /usr/local/app/src 85 | - path: ./backend/package.json 86 | action: rebuild 87 | labels: 88 | traefik.http.routers.backend.rule: Host(`localhost`) && PathPrefix(`/api`) 89 | traefik.http.services.backend.loadbalancer.server.port: 3000 90 | 91 | ################################################### 92 | # Service: client 93 | # 94 | # The client service is the React app that provides the frontend for the app. 95 | # When the container starts, it will use the image that results from building 96 | # the Dockerfile, targeting the dev stage. 97 | # 98 | # The Compose Watch configuration is used to automatically sync the code from 99 | # the host machine to the container. This allows the client to be automatically 100 | # reloaded when code changes are made. 101 | # 102 | # The labels are used to configure Traefik (the reverse proxy) with the 103 | # appropriate routing rules. In this case, all requests to localhost will be 104 | # forwarded to this service's port 5173. 105 | ################################################### 106 | client: 107 | build: 108 | context: ./ 109 | target: client-dev 110 | develop: 111 | watch: 112 | - path: ./client/src 113 | action: sync 114 | target: /usr/local/app/src 115 | - path: ./client/package.json 116 | action: rebuild 117 | labels: 118 | traefik.http.routers.client.rule: Host(`localhost`) 119 | traefik.http.services.client.loadbalancer.server.port: 5173 120 | 121 | 122 | ################################################### 123 | # Service: mysql 124 | # 125 | # The MySQL service is used to provide the database for the application. 126 | # The image for this service comes directly from Docker Hub and is a Docker 127 | # Official Image. 128 | 129 | # The data is persisted in a volume named todo-mysql-data. Using a volume 130 | # allows us to take down the services without losing the data. When we start 131 | # the services again, the data will still be there (assuming we didn't delete 132 | # the volume, of course!). 133 | # 134 | # The environment variables configure the root password and the name of the 135 | # database to create. Since these are used only for local development, it's 136 | # ok to hard-code them here. 137 | ################################################### 138 | mysql: 139 | image: mysql:9.3 140 | volumes: 141 | - todo-mysql-data:/var/lib/mysql 142 | environment: 143 | MYSQL_ROOT_PASSWORD: secret 144 | MYSQL_DATABASE: todos 145 | healthcheck: 146 | test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] 147 | interval: 5s 148 | timeout: 5s 149 | retries: 5 150 | 151 | ################################################### 152 | # Service: phpmyadmin 153 | # 154 | # This service provides a web interface to the MySQL database. It's useful 155 | # for debugging and troubleshooting data, schemas, and more. The image for 156 | # this service comes directly from Docker Hub and is a Docker Official Image. 157 | # 158 | # The environment variables configure the connection to the database and 159 | # provide the default credentials, letting us immediately open the interface 160 | # without needing to log in. 161 | # 162 | # The labels are used to configure Traefik (the reverse proxy) with the 163 | # routing rules. In this case, all requests to db.localhost will be forwarded 164 | # to this service's port 80. 165 | ################################################### 166 | phpmyadmin: 167 | image: phpmyadmin 168 | environment: 169 | PMA_HOST: mysql 170 | PMA_USER: root 171 | PMA_PASSWORD: secret 172 | labels: 173 | traefik.http.routers.phpmyadmin.rule: Host(`db.localhost`) 174 | traefik.http.services.phpmyadmin.loadbalancer.server.port: 80 175 | 176 | ################################################### 177 | # Volumes 178 | # 179 | # For this application stack, we only have one volume. It's used to persist the 180 | # data for the MySQL service. We are only going to use the default values, 181 | # hence the lack of any configuration for the volume. 182 | ################################################### 183 | volumes: 184 | todo-mysql-data: 185 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2024 Docker, Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. --------------------------------------------------------------------------------