├── api ├── src │ ├── tests │ │ ├── __init__.py │ │ ├── unit │ │ │ ├── __init__.py │ │ │ ├── features │ │ │ │ ├── __init__.py │ │ │ │ └── todo │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── entities │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── test_todo_item.py │ │ │ │ │ ├── repository │ │ │ │ │ └── __init__.py │ │ │ │ │ ├── use_cases │ │ │ │ │ ├── test_get_todo_all.py │ │ │ │ │ ├── test_update_todo.py │ │ │ │ │ ├── test_add_todo.py │ │ │ │ │ ├── test_delete_todo_by_id.py │ │ │ │ │ └── test_get_todo_by_id.py │ │ │ │ │ └── conftest.py │ │ │ └── common │ │ │ │ └── test_exception_handler_integration.py │ │ └── integration │ │ │ ├── __init__.py │ │ │ ├── features │ │ │ ├── health_check │ │ │ │ └── test_health_check_feature.py │ │ │ ├── whoami │ │ │ │ └── test_whoami_feature.py │ │ │ └── todo │ │ │ │ └── test_todo_feature.py │ │ │ ├── common │ │ │ └── test_exception_handler.py │ │ │ └── mock_authentication.py │ ├── init.sh │ ├── common │ │ ├── telemetry.py │ │ ├── logger_level.py │ │ ├── logger.py │ │ ├── responses.py │ │ ├── middleware.py │ │ └── exceptions.py │ ├── features │ │ ├── whoami │ │ │ └── whoami_feature.py │ │ ├── health_check │ │ │ └── health_check_feature.py │ │ └── todo │ │ │ ├── entities │ │ │ └── todo_item.py │ │ │ ├── use_cases │ │ │ ├── delete_todo_by_id.py │ │ │ ├── get_todo_by_id.py │ │ │ ├── update_todo.py │ │ │ ├── get_todo_all.py │ │ │ └── add_todo.py │ │ │ ├── repository │ │ │ ├── todo_repository_interface.py │ │ │ └── todo_repository.py │ │ │ └── todo_feature.py │ ├── data_providers │ │ └── clients │ │ │ ├── client_interface.py │ │ │ └── mongodb │ │ │ └── mongo_database_client.py │ ├── authentication │ │ ├── authentication.py │ │ ├── access_control.py │ │ └── models.py │ ├── config.py │ └── app.py ├── .dockerignore ├── Dockerfile └── pyproject.toml ├── documentation ├── static │ ├── .nojekyll │ └── img │ │ ├── favicon.png │ │ ├── features.png │ │ ├── clean-architecture.png │ │ ├── clean-architecture2.png │ │ ├── clean-architecture-horizontal2.png │ │ └── logo.svg ├── docs │ ├── about │ │ ├── how-to │ │ │ ├── 01-how-to-do-something.md │ │ │ └── _category_.json │ │ ├── running │ │ │ ├── _category_.json │ │ │ ├── 01-prerequisites.md │ │ │ ├── 03-starting-services.md │ │ │ └── 02-configure.md │ │ ├── concepts │ │ │ ├── _category_.json │ │ │ ├── 02-use-case.md │ │ │ └── 01-task.md │ │ ├── 01-introduction.md │ │ └── 02-overview.md │ └── contribute │ │ ├── _category_.yaml │ │ ├── development-guide │ │ ├── coding │ │ │ ├── extending-the-api │ │ │ │ ├── fast-api.png │ │ │ │ ├── adding-data-providers │ │ │ │ │ ├── index.md │ │ │ │ │ ├── 02-repository-interfaces.md │ │ │ │ │ ├── 01-clients.md │ │ │ │ │ └── 03-repositories.md │ │ │ │ ├── adding-features │ │ │ │ │ ├── index.md │ │ │ │ │ ├── 02-use-cases.md │ │ │ │ │ ├── 03-securing-endpoints.md │ │ │ │ │ └── 01-controllers.md │ │ │ │ ├── 02-adding-entities.md │ │ │ │ └── index.md │ │ │ ├── _category_.json │ │ │ ├── 03-generate-api-clients.md │ │ │ └── extending-the-web │ │ │ │ └── index.md │ │ ├── _category_.json │ │ ├── 04-upgrading.md │ │ └── 03-testing.md │ │ ├── 03-documentation.md │ │ └── 01-how-to-start-contributing.md ├── babel.config.js ├── tsconfig.json ├── README.md ├── sidebars.js ├── src │ ├── css │ │ └── custom.css │ └── pages │ │ ├── index.module.css │ │ └── index.tsx ├── package.json └── diagrams │ └── fast-api.drawio ├── .release-please-manifest.json ├── web ├── .dockerignore ├── src │ ├── vite-env.d.ts │ ├── api │ │ └── generated │ │ │ ├── models │ │ │ ├── AddTodoRequest.ts │ │ │ ├── UpdateTodoResponse.ts │ │ │ ├── DeleteTodoByIdResponse.ts │ │ │ ├── AccessLevel.ts │ │ │ ├── UpdateTodoRequest.ts │ │ │ ├── AddTodoResponse.ts │ │ │ ├── GetTodoAllResponse.ts │ │ │ ├── GetTodoByIdResponse.ts │ │ │ ├── ErrorResponse.ts │ │ │ └── User.ts │ │ │ ├── core │ │ │ ├── ApiResult.ts │ │ │ ├── ApiRequestOptions.ts │ │ │ ├── ApiError.ts │ │ │ └── OpenAPI.ts │ │ │ ├── services │ │ │ ├── HealthCheckService.ts │ │ │ └── WhoamiService.ts │ │ │ └── index.ts │ ├── pages │ │ └── TodoListPage.tsx │ ├── features │ │ └── todos │ │ │ └── todo-list │ │ │ ├── TodoItem.styled.tsx │ │ │ ├── TodoList.styled.tsx │ │ │ ├── TodoItem.tsx │ │ │ └── TodoList.tsx │ ├── common │ │ └── components │ │ │ ├── InvalidUrl.tsx │ │ │ ├── IconButton.tsx │ │ │ ├── Popover.tsx │ │ │ ├── VersionText.tsx │ │ │ └── Header.tsx │ ├── router.tsx │ ├── setupTests.ts │ ├── App.test.tsx │ ├── index.tsx │ ├── auth.ts │ ├── App.tsx │ ├── hooks │ │ └── useTodoAPI.tsx │ └── contexts │ │ └── TodoContext.tsx ├── nginx │ ├── config │ │ ├── general.conf │ │ ├── websocket.conf │ │ ├── security.conf │ │ └── proxy.conf │ ├── environments │ │ ├── web.dev.conf │ │ └── web.prod.conf │ ├── sites-available │ │ └── default.conf │ └── nginx.conf ├── public │ ├── favicon.ico │ ├── Equinor_Diamond_Favicon_RED_32x32px.png │ ├── Equinor_Symbol_Favicon_RED_64x64px.png │ └── manifest.json ├── vitest.config.mts ├── tsconfig.json ├── index.html ├── biome.json ├── vite.config.mts ├── README.md ├── generate-api-typescript-client-pre-commit.sh ├── package.json └── Dockerfile ├── .vscode ├── settings.json └── extensions.json ├── IaC ├── bicepconfig.json ├── main.bicep ├── exceptionEmailNotification.bicep └── app-registration.bicep ├── .github ├── CODEOWNERS ├── pull_request_template.md ├── ISSUE_TEMPLATE │ ├── bug-report.md │ ├── code-maintenance.md │ └── feature-request.md ├── workflows │ ├── on-pull-request.yaml │ ├── create-release-pr.yaml │ ├── rollback.yaml │ ├── on-push-main-branch.yaml │ ├── publish-docs.yaml │ ├── generate-changelog.yaml │ ├── linting-and-checks.yaml │ ├── codeql.yaml │ ├── deploy-to-radix.yaml │ ├── publish-image.yaml │ └── tests.yaml └── dependabot.yml ├── CONTRIBUTING.md ├── .gitattributes ├── docker-compose.yml ├── docker-compose.ci.yml ├── SECURITY.md ├── .env-template ├── LICENSE.md ├── release-please-config.json ├── docker-compose.override.yml ├── .pre-commit-config.yaml ├── README.md ├── radixconfig.yaml └── .gitignore /api/src/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/src/tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /documentation/static/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/src/tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/src/tests/unit/features/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | {".":"1.5.0"} 2 | -------------------------------------------------------------------------------- /api/src/tests/unit/features/todo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | src/build 3 | -------------------------------------------------------------------------------- /api/src/tests/unit/features/todo/entities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/src/tests/unit/features/todo/repository/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/.dockerignore: -------------------------------------------------------------------------------- 1 | .venv 2 | .pytest_cache 3 | .mypy_cache 4 | -------------------------------------------------------------------------------- /web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /web/nginx/config/general.conf: -------------------------------------------------------------------------------- 1 | add_header Last-Modified $date_gmt; 2 | -------------------------------------------------------------------------------- /documentation/docs/about/how-to/01-how-to-do-something.md: -------------------------------------------------------------------------------- 1 | # How to do something 2 | -------------------------------------------------------------------------------- /documentation/docs/contribute/_category_.yaml: -------------------------------------------------------------------------------- 1 | position: 5 2 | collapsed: true 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "**/.yarn": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /IaC/bicepconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "experimentalFeaturesEnabled": { 3 | "extensibility": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equinor/template-fastapi-react/HEAD/web/public/favicon.ico -------------------------------------------------------------------------------- /web/nginx/config/websocket.conf: -------------------------------------------------------------------------------- 1 | proxy_set_header Upgrade $http_upgrade; 2 | proxy_set_header Connection "upgrade"; 3 | -------------------------------------------------------------------------------- /documentation/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /documentation/static/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equinor/template-fastapi-react/HEAD/documentation/static/img/favicon.png -------------------------------------------------------------------------------- /api/src/init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | if [ "$1" = 'api' ]; then 5 | exec python3 ./app.py run 6 | else 7 | exec "$@" 8 | fi 9 | -------------------------------------------------------------------------------- /documentation/static/img/features.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equinor/template-fastapi-react/HEAD/documentation/static/img/features.png -------------------------------------------------------------------------------- /documentation/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/docusaurus/tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "." 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /documentation/static/img/clean-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equinor/template-fastapi-react/HEAD/documentation/static/img/clean-architecture.png -------------------------------------------------------------------------------- /documentation/static/img/clean-architecture2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equinor/template-fastapi-react/HEAD/documentation/static/img/clean-architecture2.png -------------------------------------------------------------------------------- /documentation/docs/about/running/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Running", 3 | "position": 4, 4 | "link": { 5 | "type": "generated-index" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /web/public/Equinor_Diamond_Favicon_RED_32x32px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equinor/template-fastapi-react/HEAD/web/public/Equinor_Diamond_Favicon_RED_32x32px.png -------------------------------------------------------------------------------- /web/public/Equinor_Symbol_Favicon_RED_64x64px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equinor/template-fastapi-react/HEAD/web/public/Equinor_Symbol_Favicon_RED_64x64px.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "arcanis.vscode-zipfs", 4 | "dbaeumer.vscode-eslint", 5 | "esbenp.prettier-vscode" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /api/src/common/telemetry.py: -------------------------------------------------------------------------------- 1 | from opentelemetry import trace 2 | 3 | # Creates a tracer from the global tracer provider 4 | tracer = trace.get_tracer("tracer.global") 5 | -------------------------------------------------------------------------------- /documentation/static/img/clean-architecture-horizontal2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equinor/template-fastapi-react/HEAD/documentation/static/img/clean-architecture-horizontal2.png -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Default/fallback ownership 2 | * @equinor/team-hermes 3 | 4 | # Backend 5 | /api/ @equinor/team-hermes-backend 6 | 7 | # Frontend 8 | /web/ @equinor/team-hermes-frontend 9 | -------------------------------------------------------------------------------- /documentation/docs/about/how-to/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "How to", 3 | "position": 4, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "General introductions to complex topics" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /documentation/docs/contribute/development-guide/coding/extending-the-api/fast-api.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equinor/template-fastapi-react/HEAD/documentation/docs/contribute/development-guide/coding/extending-the-api/fast-api.png -------------------------------------------------------------------------------- /web/vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: 'jsdom', 7 | setupFiles: './src/setupTests.ts', 8 | }, 9 | }) 10 | -------------------------------------------------------------------------------- /documentation/docs/about/concepts/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Concepts", 3 | "position": 3, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "General introductions to complex domain-specific topics." 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /web/src/api/generated/models/AddTodoRequest.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do not edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | export type AddTodoRequest = { 6 | title: string; 7 | }; 8 | 9 | -------------------------------------------------------------------------------- /web/src/api/generated/models/UpdateTodoResponse.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do not edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | export type UpdateTodoResponse = { 6 | success: boolean; 7 | }; 8 | 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guide 2 | 3 | Want to contribute to the template? There are a few things you need to know. 4 | 5 | We wrote a [contribution guide](https://equinor.github.io/template-fastapi-react/docs/contribute/how-to-start-contributing) to help you get started. 6 | -------------------------------------------------------------------------------- /web/src/api/generated/models/DeleteTodoByIdResponse.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do not edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | export type DeleteTodoByIdResponse = { 6 | success: boolean; 7 | }; 8 | 9 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Why is this pull request needed? 2 | 3 | This pull request is needed because of.... 4 | 5 | ## What does this pull request change? 6 | 7 | Write summary of what this pull request changes if needed. 8 | 9 | ## Issues related to this change: 10 | -------------------------------------------------------------------------------- /documentation/docs/about/running/01-prerequisites.md: -------------------------------------------------------------------------------- 1 | # Prerequisites 2 | 3 | In order to run you need to have installed: 4 | 5 | - [Docker](https://www.docker.com/) 6 | - [Docker Compose](https://docs.docker.com/compose/) 7 | - Git 8 | - [Python](https://www.python.org/) (3.10 or newer) 9 | -------------------------------------------------------------------------------- /web/src/api/generated/models/AccessLevel.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do not edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | export enum AccessLevel { 6 | WRITE = 2, 7 | READ = 1, 8 | NONE = 0, 9 | } 10 | -------------------------------------------------------------------------------- /documentation/docs/contribute/development-guide/coding/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Coding", 3 | "position": 2, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "This section of the documentation lists instructions and guidelines on how to start coding" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /web/nginx/environments/web.dev.conf: -------------------------------------------------------------------------------- 1 | proxy_pass http://web:3000/; 2 | include /etc/nginx/config/security.conf; 3 | include /etc/nginx/config/general.conf; 4 | include /etc/nginx/config/proxy.conf; 5 | include /etc/nginx/config/websocket.conf; 6 | -------------------------------------------------------------------------------- /web/src/pages/TodoListPage.tsx: -------------------------------------------------------------------------------- 1 | import { TodoProvider } from '../contexts/TodoContext' 2 | import TodoList from '../features/todos/todo-list/TodoList' 3 | 4 | export const TodoListPage = () => { 5 | return ( 6 | 7 | 8 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /documentation/docs/contribute/development-guide/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Development guide", 3 | "position": 2, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "This section of the documentation lists instructions and guidelines on how to start developing" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /web/src/api/generated/models/UpdateTodoRequest.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do not edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | export type UpdateTodoRequest = { 6 | title?: string; 7 | is_completed: boolean; 8 | }; 9 | 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report something that is broken or not working as intended 4 | title: '' 5 | labels: 'type: :bug bug' 6 | assignees: '' 7 | --- 8 | 9 | #### Expected Behaviour 10 | 11 | #### Actual Behaviour 12 | 13 | #### Steps to Reproduce 14 | -------------------------------------------------------------------------------- /web/nginx/environments/web.prod.conf: -------------------------------------------------------------------------------- 1 | root /data/www/; 2 | include /etc/nginx/config/security.conf; 3 | include /etc/nginx/config/general.conf; 4 | include /etc/nginx/config/websocket.conf; 5 | index index.html; 6 | try_files $uri $uri/ /index.html; 7 | -------------------------------------------------------------------------------- /api/src/common/logger_level.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class LoggerLevel(Enum): 5 | """Enum containing the different levels for logging.""" 6 | 7 | CRITICAL = "critical" 8 | ERROR = "error" 9 | WARNING = "warning" 10 | INFO = "info" 11 | DEBUG = "debug" 12 | TRACE = "trace" 13 | -------------------------------------------------------------------------------- /web/src/api/generated/models/AddTodoResponse.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do not edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | export type AddTodoResponse = { 6 | id: string; 7 | title: string; 8 | is_completed?: boolean; 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/code-maintenance.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Code Maintenance 3 | about: Project cleanup, improve documentation, refactor code 4 | title: '' 5 | labels: 'type: :wrench: maintenance' 6 | assignees: '' 7 | 8 | --- 9 | 10 | #### Describe Problem 11 | 12 | #### Suggest Changes 13 | 14 | #### Provide Examples 15 | -------------------------------------------------------------------------------- /web/src/api/generated/models/GetTodoAllResponse.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do not edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | export type GetTodoAllResponse = { 6 | id: string; 7 | title: string; 8 | is_completed: boolean; 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /web/src/api/generated/models/GetTodoByIdResponse.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do not edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | export type GetTodoByIdResponse = { 6 | id: string; 7 | title: string; 8 | is_completed?: boolean; 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /web/src/features/todos/todo-list/TodoItem.styled.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from '@equinor/eds-core-react' 2 | import styled from 'styled-components' 3 | 4 | export const StyledTodoItemTitle = styled(Typography)<{ $isStruckThrough?: boolean }>` 5 | text-decoration: ${(props) => (props.$isStruckThrough ? 'line-through' : 'none')}; 6 | ` 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for a new feature or enhancement to existing features 4 | title: '' 5 | labels: 'type: :bulb: feature request' 6 | assignees: '' 7 | --- 8 | 9 | #### Describe Problem 10 | 11 | #### Suggest Solution 12 | 13 | #### Additional Details 14 | -------------------------------------------------------------------------------- /documentation/docs/contribute/development-guide/coding/03-generate-api-clients.md: -------------------------------------------------------------------------------- 1 | # Generate API clients 2 | 3 | To generate typescript client for API run: 4 | 5 | ```shell 6 | cd web 7 | ./generate-api-client.sh 8 | ``` 9 | 10 | This will populate `web/src/api/generated` with new typescript files that matches the API OpenAPI specification. 11 | -------------------------------------------------------------------------------- /.github/workflows/on-pull-request.yaml: -------------------------------------------------------------------------------- 1 | name: "On PR updated" 2 | on: 3 | pull_request: 4 | types: [opened, reopened, synchronize] 5 | 6 | jobs: 7 | linting-and-checks: 8 | name: "Linting and checks" 9 | uses: ./.github/workflows/linting-and-checks.yaml 10 | 11 | tests: 12 | name: "Tests" 13 | uses: ./.github/workflows/tests.yaml 14 | -------------------------------------------------------------------------------- /web/src/common/components/InvalidUrl.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom' 2 | 3 | const InvalidUrl = () => { 4 | return ( 5 |
6 |

Invalid url. Please go back to the:

7 | home page 8 |
9 | ) 10 | } 11 | 12 | export default InvalidUrl 13 | -------------------------------------------------------------------------------- /web/src/api/generated/core/ApiResult.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do not edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | export type ApiResult = { 6 | readonly url: string; 7 | readonly ok: boolean; 8 | readonly status: number; 9 | readonly statusText: string; 10 | readonly body: any; 11 | }; 12 | -------------------------------------------------------------------------------- /web/src/api/generated/models/ErrorResponse.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do not edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | export type ErrorResponse = { 6 | status?: number; 7 | type?: string; 8 | message?: string; 9 | debug?: string; 10 | extra?: (Record | null); 11 | }; 12 | 13 | -------------------------------------------------------------------------------- /web/src/router.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter } from 'react-router-dom' 2 | import InvalidUrl from './common/components/InvalidUrl' 3 | import { TodoListPage } from './pages/TodoListPage' 4 | 5 | export const router = createBrowserRouter([ 6 | { 7 | path: '/', 8 | element: , 9 | }, 10 | { 11 | path: '*', 12 | element: , 13 | }, 14 | ]) 15 | -------------------------------------------------------------------------------- /api/src/tests/integration/features/health_check/test_health_check_feature.py: -------------------------------------------------------------------------------- 1 | from starlette.status import HTTP_200_OK 2 | from starlette.testclient import TestClient 3 | 4 | 5 | class TestTodo: 6 | def test_get(self, test_app: TestClient): 7 | response = test_app.get("/health-check") 8 | assert response.status_code == HTTP_200_OK 9 | assert response.content == b"OK" 10 | -------------------------------------------------------------------------------- /web/nginx/config/security.conf: -------------------------------------------------------------------------------- 1 | # security headers 2 | add_header X-XSS-Protection "1; mode=block" always; 3 | add_header X-Content-Type-Options "nosniff" always; 4 | add_header Referrer-Policy "no-referrer-when-downgrade" always; 5 | add_header Permissions-Policy "interest-cohort=()" always; 6 | add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; 7 | -------------------------------------------------------------------------------- /web/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom' 6 | import { cleanup } from '@testing-library/react' 7 | import { afterEach } from 'vitest' 8 | 9 | afterEach(() => { 10 | cleanup() 11 | }) 12 | -------------------------------------------------------------------------------- /web/src/api/generated/models/User.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do not edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | import type { AccessLevel } from './AccessLevel'; 6 | export type User = { 7 | user_id: string; 8 | email?: (string | null); 9 | full_name?: (string | null); 10 | roles?: Array; 11 | scope?: AccessLevel; 12 | }; 13 | 14 | -------------------------------------------------------------------------------- /web/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import App from './App' 3 | 4 | test('renders without crashing', () => { 5 | render() 6 | }) 7 | test('has an input field', () => { 8 | render() 9 | expect(screen.getByPlaceholderText('Add Task')).toBeDefined() 10 | }) 11 | test('has an Add button', () => { 12 | render() 13 | expect(screen.getByText('Add')).toBeDefined() 14 | }) 15 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # Declare files that will always have LF line endings on checkout. 5 | *.sh text eol=lf 6 | 7 | # Denote all files that are truly binary and should not be modified. 8 | *.png binary 9 | *.jpg binary 10 | 11 | # Ignore massive diffs each time you add/update yarn plugins 12 | /web/.yarn/releases/** binary 13 | /web/.yarn/plugins/** binary 14 | -------------------------------------------------------------------------------- /api/src/tests/unit/features/todo/use_cases/test_get_todo_all.py: -------------------------------------------------------------------------------- 1 | from features.todo.repository.todo_repository_interface import TodoRepositoryInterface 2 | from features.todo.use_cases.get_todo_all import get_todo_all_use_case 3 | 4 | 5 | def test_get_todos_should_return_todos(todo_repository: TodoRepositoryInterface, todo_test_data: dict[str, dict]): 6 | todos = get_todo_all_use_case(user_id="xyz", todo_repository=todo_repository) 7 | assert len(todos) == len(todo_test_data.keys()) 8 | -------------------------------------------------------------------------------- /documentation/docs/about/concepts/02-use-case.md: -------------------------------------------------------------------------------- 1 | # Use case 2 | 3 | There will be one use case for each individual action/command of an actor. An actor is a person or another system that interacts with our application. Typically, it will be a regular user. 4 | 5 | ## Examples 6 | 7 | For a meetup.com clone, it could be: 8 | 9 | * Confirming attendance as an attendee 10 | * Cancelling attendance as an attendee 11 | * Drafting new meeting as an organizer 12 | 13 | ## Related concepts 14 | -------------------------------------------------------------------------------- /api/src/common/logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Setup API and Uvicorn logger. 3 | """ 4 | 5 | import logging 6 | 7 | from config import config 8 | 9 | uvicorn_logger = logging.getLogger("uvicorn") 10 | 11 | logger = logging.getLogger("API") 12 | logger.setLevel(config.log_level.upper()) 13 | formatter = logging.Formatter("%(levelname)s:%(asctime)s %(message)s") 14 | channel = logging.StreamHandler() 15 | channel.setFormatter(formatter) 16 | channel.setLevel(config.log_level.upper()) 17 | logger.addHandler(channel) 18 | -------------------------------------------------------------------------------- /web/nginx/config/proxy.conf: -------------------------------------------------------------------------------- 1 | proxy_http_version 1.1; 2 | 3 | proxy_set_header Host $host; 4 | proxy_set_header X-Real-IP $remote_addr; 5 | proxy_set_header Forwarded $proxy_add_forwarded; 6 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 7 | proxy_set_header X-Forwarded-Proto $scheme; 8 | proxy_set_header X-Forwarded-Host $host; 9 | proxy_set_header X-Forwarded-Port $server_port; 10 | 11 | proxy_set_header X-Request-Start $msec; 12 | -------------------------------------------------------------------------------- /web/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Template React App", 4 | "icons": [ 5 | { 6 | "src": "Equinor_Diamond_Favicon_RED_32x32px.png", 7 | "sizes": "32x32", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "Equinor_Symbol_Favicon_RED_64x64px.png", 12 | "type": "image/png", 13 | "sizes": "64x64" 14 | } 15 | ], 16 | "start_url": ".", 17 | "display": "standalone", 18 | "theme_color": "#000000", 19 | "background_color": "#ffffff" 20 | } 21 | -------------------------------------------------------------------------------- /api/src/features/whoami/whoami_feature.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | 3 | from authentication.authentication import auth_with_jwt 4 | from authentication.models import User 5 | from common.exception_handlers import ExceptionHandlingRoute 6 | 7 | router = APIRouter(tags=["whoami"], prefix="/whoami", route_class=ExceptionHandlingRoute) 8 | 9 | 10 | @router.get("", operation_id="whoami") 11 | async def get_information_on_authenticated_user( 12 | user: User = Depends(auth_with_jwt), 13 | ) -> User: 14 | return user 15 | -------------------------------------------------------------------------------- /api/src/features/health_check/health_check_feature.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, status 2 | from fastapi.responses import PlainTextResponse 3 | 4 | from common.exception_handlers import ExceptionHandlingRoute 5 | 6 | router = APIRouter(tags=["health_check"], prefix="/health-check", route_class=ExceptionHandlingRoute) 7 | 8 | 9 | @router.get( 10 | "", 11 | responses={status.HTTP_200_OK: {"model": str, "content": {"plain/text": {"example": "OK"}}}}, 12 | ) 13 | async def get() -> PlainTextResponse: 14 | return PlainTextResponse("OK") 15 | -------------------------------------------------------------------------------- /api/src/tests/unit/features/todo/use_cases/test_update_todo.py: -------------------------------------------------------------------------------- 1 | from features.todo.repository.todo_repository_interface import TodoRepositoryInterface 2 | from features.todo.use_cases.update_todo import UpdateTodoRequest, update_todo_use_case 3 | 4 | 5 | def test_update_todo_should_return_success(todo_repository: TodoRepositoryInterface): 6 | id = "dh2109" 7 | data = UpdateTodoRequest(title="new title", is_completed=False) 8 | result = update_todo_use_case(id, data, user_id="xyz", todo_repository=todo_repository) 9 | assert result.success 10 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | nginx: 3 | image: ghcr.io/equinor/template-fastapi-react/nginx:latest 4 | restart: unless-stopped 5 | build: 6 | target: nginx-prod 7 | context: ./web 8 | ports: 9 | - "80:8080" 10 | links: 11 | - api 12 | 13 | api: 14 | image: ghcr.io/equinor/template-fastapi-react/api 15 | build: ./api 16 | restart: unless-stopped 17 | depends_on: 18 | - db 19 | 20 | db: 21 | image: mongo:7.0.17 22 | restart: unless-stopped 23 | command: mongod --auth --quiet 24 | -------------------------------------------------------------------------------- /documentation/docs/about/01-introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | ## The purpose of the template 4 | 5 | This is a solution template for creating a Single Page App (SPA) with React and FastAPI following the principles of Clean Architecture. 6 | 7 | The template provides an example implementation of a todo application. The todo app implementation is fairly basic. A user can add a task, mark a task as completed and delete an added task. The purpose of the minimalist todo app implementation is to learn and practice what the concepts of [clean architecture](../contribute/development-guide/coding/01-architecture.md) are, and how they can be used in a REST API. 8 | -------------------------------------------------------------------------------- /web/src/features/todos/todo-list/TodoList.styled.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export const StyledTodoList = styled.div` 4 | display: flex; 5 | flex-direction: column; 6 | gap: 0.25rem;; 7 | width: 400px; 8 | padding: 30px; 9 | position: relative; 10 | ` 11 | 12 | export const SpinnerContainer = styled.div` 13 | position: absolute; 14 | inset: 0; 15 | display: flex; 16 | justify-content: center; 17 | align-items: center; 18 | background: rgba(255, 255, 255, 0.4); 19 | ` 20 | 21 | export const StyledInput = styled.div` 22 | display: flex; 23 | 24 | .input { 25 | margin-right: 5px; 26 | } 27 | ` 28 | -------------------------------------------------------------------------------- /docker-compose.ci.yml: -------------------------------------------------------------------------------- 1 | services: 2 | api: 3 | build: 4 | target: development 5 | image: development 6 | environment: 7 | ENVIRONMENT: local 8 | MONGODB_DATABASE: db 9 | MONGODB_USERNAME: root 10 | MONGODB_PASSWORD: mongodb 11 | MONGODB_HOSTNAME: db 12 | MONGODB_PORT: 27017 13 | SECRET_KEY: sg9aeUM5i1JO4gNN8fQadokJa3_gXQMLBjSGGYcfscs= 14 | AUTH_ENABLED: "False" 15 | depends_on: 16 | - db 17 | links: 18 | - db 19 | 20 | db: 21 | environment: 22 | MONGO_INITDB_ROOT_USERNAME: root 23 | MONGO_INITDB_ROOT_PASSWORD: mongodb 24 | MONGO_INITDB_DATABASE: db 25 | -------------------------------------------------------------------------------- /documentation/docs/contribute/development-guide/coding/extending-the-api/adding-data-providers/index.md: -------------------------------------------------------------------------------- 1 | # Adding data providers 2 | 3 | Data providers are part of the infrastructure layer, which is responsible for external infrastructure communications 4 | like database storage, file system, and external systems. The infrastructure layer is the layer that contains all the 5 | concrete implementations of the application. It implements interfaces defined in use cases, to provide access to 6 | external systems. 7 | 8 | ``` 9 | ├── data_providers/ 10 | │ ├── clients/ 11 | │ ├── repository_interfaces/ 12 | │ └── repositories/ 13 | └── ... 14 | ``` 15 | 16 | w 17 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "types": ["vite/client", "vite-plugin-svgr/client", "jest"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "module": "ESNext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx" 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /api/src/features/todo/entities/todo_item.py: -------------------------------------------------------------------------------- 1 | from dataclasses import asdict, dataclass, fields 2 | 3 | 4 | @dataclass(frozen=True) 5 | class TodoItem: 6 | id: str 7 | user_id: str 8 | title: str 9 | is_completed: bool = False 10 | 11 | def to_dict(self) -> dict[str, str | bool]: 12 | return asdict(self) 13 | 14 | @classmethod 15 | def from_dict(cls, dict_: dict[str, str | bool]) -> "TodoItem": 16 | class_fields = {f.name for f in fields(cls)} 17 | if "_id" in dict_: 18 | dict_["id"] = dict_.pop("_id") 19 | data = {k: v for k, v in dict_.items() if k in class_fields} 20 | return TodoItem(**data) # type:ignore 21 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Template | FastAPI React 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { AuthProvider } from 'react-oauth2-code-pkce' 4 | import App from './App' 5 | import { OpenAPI } from './api/generated' 6 | import { authConfig } from './auth' 7 | 8 | const hasAuthConfig = import.meta.env.VITE_AUTH === '1' 9 | 10 | OpenAPI.BASE = `${window.location.origin}/api` 11 | 12 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement) 13 | root.render( 14 | 15 | {hasAuthConfig ? ( 16 | 17 | 18 | 19 | ) : ( 20 | 21 | )} 22 | 23 | ) 24 | -------------------------------------------------------------------------------- /documentation/docs/contribute/development-guide/coding/extending-the-api/adding-data-providers/02-repository-interfaces.md: -------------------------------------------------------------------------------- 1 | # Repository interfaces 2 | 3 | A repository interface describes the incoming parameters and the type of the object returned by a repository. The 4 | purpose of these interfaces is to allow use-cases to be implementation-agnostic (and thus not depend on an outer layer). 5 | It also allows for mocking of repositories for testing purposes. 6 | 7 | ```mdx-code-block 8 | import CodeBlock from '@theme/CodeBlock'; 9 | 10 | import TodoRepositoryInterface from '!!raw-loader!@site/../api/src/features/todo/repository/todo_repository_interface.py'; 11 | 12 | {TodoRepositoryInterface} 13 | ``` 14 | -------------------------------------------------------------------------------- /api/src/features/todo/use_cases/delete_todo_by_id.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from common.exceptions import MissingPrivilegeException, NotFoundException 4 | from features.todo.repository.todo_repository_interface import TodoRepositoryInterface 5 | 6 | 7 | class DeleteTodoByIdResponse(BaseModel): 8 | success: bool 9 | 10 | 11 | def delete_todo_use_case(id: str, user_id: str, todo_repository: TodoRepositoryInterface) -> DeleteTodoByIdResponse: 12 | todo_item = todo_repository.get(id) 13 | if todo_item is None: 14 | raise NotFoundException 15 | if todo_item.user_id != user_id: 16 | raise MissingPrivilegeException 17 | todo_repository.delete(id) 18 | return DeleteTodoByIdResponse(success=True) 19 | -------------------------------------------------------------------------------- /web/src/api/generated/core/ApiRequestOptions.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do not edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | export type ApiRequestOptions = { 6 | readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; 7 | readonly url: string; 8 | readonly path?: Record; 9 | readonly cookies?: Record; 10 | readonly headers?: Record; 11 | readonly query?: Record; 12 | readonly formData?: Record; 13 | readonly body?: any; 14 | readonly mediaType?: string; 15 | readonly responseHeader?: string; 16 | readonly errors?: Record; 17 | }; 18 | -------------------------------------------------------------------------------- /.github/workflows/create-release-pr.yaml: -------------------------------------------------------------------------------- 1 | name: "Create release-please PR" 2 | 3 | on: 4 | workflow_dispatch: 5 | workflow_call: 6 | outputs: 7 | release_created: 8 | description: "If true, a release PR has been merged" 9 | value: ${{ jobs.release-please.outputs.release_created }} 10 | tag_name: 11 | description: "The release tag. Ex v1.4.0" 12 | value: ${{ jobs.release-please.outputs.tag_name }} 13 | 14 | jobs: 15 | release-please: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: googleapis/release-please-action@v4 19 | id: release 20 | outputs: 21 | release_created: ${{ steps.release.outputs.release_created }} 22 | tag_name: ${{ steps.release.outputs.tag_name }} 23 | -------------------------------------------------------------------------------- /documentation/docs/contribute/development-guide/coding/extending-the-api/adding-features/index.md: -------------------------------------------------------------------------------- 1 | # Adding features 2 | 3 | A feature has this structure. 4 | 5 | ``` 6 | ├── todo/ 7 | │ ├── use_cases/ - Application logic 8 | │ ├── exceptions.py - Exceptions classes (optional) 9 | │ └── controller.py - The entrypoint 10 | └── ... 11 | ``` 12 | 13 | Define endpoints in the controller that calls use cases that implements the application logic. 14 | 15 | ## Register a feature 16 | 17 | Import the router of the feature and include it to the app. 18 | 19 | ```mdx-code-block 20 | import CodeBlock from '@theme/CodeBlock'; 21 | 22 | import UseCase from '!!raw-loader!@site/../api/src/app.py'; 23 | 24 | {UseCase} 25 | ``` 26 | -------------------------------------------------------------------------------- /api/src/tests/unit/features/todo/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from features.todo.repository.todo_repository import TodoRepository 4 | 5 | 6 | @pytest.fixture(scope="function") 7 | def todo_test_data() -> dict[str, dict]: 8 | return { 9 | "dh2109": {"_id": "dh2109", "title": "item 1", "is_completed": False, "user_id": "xyz"}, 10 | "1417b8": {"_id": "1417b8", "title": "item 2", "is_completed": True, "user_id": "xyz"}, 11 | "abcdefg": {"_id": "abcdefg", "title": "item 3", "is_completed": False, "user_id": "xyz"}, 12 | } 13 | 14 | 15 | @pytest.fixture(scope="function") 16 | def todo_repository(test_client, todo_test_data): 17 | test_client.insert_many(todo_test_data.values()) 18 | yield TodoRepository(client=test_client) 19 | -------------------------------------------------------------------------------- /web/src/common/components/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Icon, Tooltip } from '@equinor/eds-core-react' 2 | import type { IconData } from '@equinor/eds-icons' 3 | import { type Ref, forwardRef } from 'react' 4 | 5 | interface IconButtonProps { 6 | title: string 7 | icon: IconData 8 | onClick: () => Promise | void 9 | } 10 | 11 | const IconButton = forwardRef(function IconButton( 12 | { title, icon, onClick }: IconButtonProps, 13 | ref: Ref 14 | ) { 15 | return ( 16 | 17 | 20 | 21 | ) 22 | }) 23 | 24 | export default IconButton 25 | -------------------------------------------------------------------------------- /web/src/auth.ts: -------------------------------------------------------------------------------- 1 | import type { TAuthConfig } from 'react-oauth2-code-pkce' 2 | 3 | export const authConfig: TAuthConfig = { 4 | clientId: import.meta.env.VITE_AUTH_CLIENT_ID || '', 5 | authorizationEndpoint: import.meta.env.VITE_AUTH_ENDPOINT || '', 6 | tokenEndpoint: import.meta.env.VITE_TOKEN_ENDPOINT || '', 7 | scope: import.meta.env.VITE_AUTH_SCOPE || '', 8 | redirectUri: window.origin, 9 | logoutEndpoint: import.meta.env.VITE_LOGOUT_ENDPOINT || '', 10 | autoLogin: false, 11 | preLogin: () => 12 | localStorage.setItem( 13 | 'preLoginPath', 14 | `${window.location.pathname}${window.location.search}${window.location.hash}` 15 | ), 16 | postLogin: () => 17 | window.location.replace(localStorage.getItem('preLoginPath') ?? (import.meta.env.VITE_AUTH_REDIRECT_URI || '')), 18 | } 19 | -------------------------------------------------------------------------------- /IaC/main.bicep: -------------------------------------------------------------------------------- 1 | targetScope='subscription' 2 | 3 | @allowed([ 'dev', 'staging', 'prod' ]) 4 | param environment string 5 | @description('Specifies the location for resources.') 6 | param resourceGroupLocation string = 'norwayeast' 7 | @description('Create admin password for the database. Will be stored in the KeyVault') 8 | @secure() 9 | param postgresDBPassword string 10 | 11 | resource newRG 'Microsoft.Resources/resourceGroups@2024-03-01' = { 12 | name: 'template-fastapi-react-${environment}' 13 | location: resourceGroupLocation 14 | } 15 | 16 | module resources 'resources.bicep' = { 17 | name: 'template-fastapi-react-${environment}-resources' 18 | scope: newRG 19 | params: { 20 | storageLocation: resourceGroupLocation 21 | environment: environment 22 | postgresDBPassword: postgresDBPassword 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /web/src/common/components/Popover.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Popover as EDSPopover } from '@equinor/eds-core-react' 2 | 3 | interface PopoverProps { 4 | children: React.ReactNode 5 | title: string 6 | toggle: () => void 7 | isOpen: boolean 8 | anchor?: HTMLElement | null 9 | } 10 | 11 | const Popover = ({ children, title, toggle, isOpen, anchor }: PopoverProps) => { 12 | return ( 13 | 14 | 15 | {title} 16 | 17 | {children} 18 | 19 | 20 | 21 | 22 | ) 23 | } 24 | 25 | export default Popover 26 | -------------------------------------------------------------------------------- /api/src/features/todo/repository/todo_repository_interface.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | from features.todo.entities.todo_item import TodoItem 4 | 5 | 6 | class TodoRepositoryInterface(metaclass=ABCMeta): 7 | @abstractmethod 8 | def create(self, todo_item: TodoItem) -> TodoItem | None: 9 | raise NotImplementedError 10 | 11 | @abstractmethod 12 | def get(self, todo_item_id: str) -> TodoItem: 13 | raise NotImplementedError 14 | 15 | @abstractmethod 16 | def update(self, todo_item: TodoItem) -> TodoItem: 17 | raise NotImplementedError 18 | 19 | @abstractmethod 20 | def delete(self, todo_item_id: str) -> None: 21 | raise NotImplementedError 22 | 23 | @abstractmethod 24 | def get_all(self) -> list[TodoItem]: 25 | raise NotImplementedError 26 | -------------------------------------------------------------------------------- /api/src/tests/integration/features/whoami/test_whoami_feature.py: -------------------------------------------------------------------------------- 1 | from starlette.status import HTTP_200_OK 2 | from starlette.testclient import TestClient 3 | 4 | from authentication.models import User 5 | from config import config 6 | from tests.integration.mock_authentication import get_mock_jwt_token 7 | 8 | 9 | class TestWhoami: 10 | def test_whoami(self, test_app: TestClient): 11 | config.AUTH_ENABLED = True 12 | config.TEST_TOKEN = True 13 | user = User(user_id="1", email="foo@bar.baz", roles=["a"]) 14 | headers = {"Authorization": f"Bearer {get_mock_jwt_token(user)}"} 15 | response = test_app.get("/whoami", headers=headers) 16 | data = response.json() 17 | assert response.status_code == HTTP_200_OK 18 | assert data["roles"][0] == "a" 19 | assert data["user_id"] == "1" 20 | -------------------------------------------------------------------------------- /documentation/docs/about/running/03-starting-services.md: -------------------------------------------------------------------------------- 1 | # Starting services 2 | 3 | You can start running: 4 | 5 | ```shell 6 | docker compose up 7 | ``` 8 | 9 | The web app will be served at [http://localhost](http://localhost) 10 | 11 | The API documentation can be found at [http://localhost/api/docs](http://localhost/api/docs) 12 | 13 | The OpenAPI spec can be found at [http://localhost/api/openapi.json](http://localhost/api/openapi.json) 14 | 15 | 16 |
17 | Skip Docker (not recommended) 18 | 19 | Navigate to the /api folder, activate local venv, then start backend app.py with Uvicorn: 20 | 21 | ```shell 22 | cd api/src/ # go to the location of app.py 23 | uvicorn app:create_app --reload 24 | ``` 25 | 26 | Navigate to the /web folder, and then start web application: 27 | 28 | ```shell 29 | yarn start 30 | ``` 31 | 32 |
33 | -------------------------------------------------------------------------------- /api/src/tests/unit/features/todo/use_cases/test_add_todo.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pydantic import ValidationError 3 | 4 | from features.todo.repository.todo_repository_interface import TodoRepositoryInterface 5 | from features.todo.use_cases.add_todo import AddTodoRequest, add_todo_use_case 6 | 7 | 8 | def test_add_with_valid_title_should_return_todo(todo_repository: TodoRepositoryInterface): 9 | data = AddTodoRequest(title="new todo") 10 | result = add_todo_use_case(data, user_id="xyz", todo_repository=todo_repository) 11 | assert result.title == data.title 12 | 13 | 14 | def test_add_with_empty_title_should_throw_validation_error(todo_repository: TodoRepositoryInterface): 15 | with pytest.raises(ValidationError): 16 | data = AddTodoRequest(title="") 17 | add_todo_use_case(data, user_id="xyz", todo_repository=todo_repository) 18 | -------------------------------------------------------------------------------- /documentation/docs/contribute/development-guide/coding/extending-the-api/adding-data-providers/01-clients.md: -------------------------------------------------------------------------------- 1 | # Clients 2 | 3 | The template already ships with a mongo database client for connecting to MongoDB databases. However, if you need a client that can talk to e.g. PostgreSQL you need to add this. 4 | 5 | ```mdx-code-block 6 | import CodeBlock from '@theme/CodeBlock'; 7 | 8 | import MongoClient from '!!raw-loader!@site/../api/src/data_providers/clients/mongodb/mongo_database_client.py'; 9 | 10 | {MongoClient} 11 | ``` 12 | 13 | ## Testing clients 14 | 15 | The `test_client` fixture are using the mongomock instead of real database. 16 | 17 | ```mdx-code-block 18 | import Test from '!!raw-loader!@site/../api/src/tests/unit/data_providers/clients/mongodb/test_mongo_database_client.py'; 19 | 20 | {Test} 21 | ``` 22 | -------------------------------------------------------------------------------- /api/src/tests/unit/features/todo/use_cases/test_delete_todo_by_id.py: -------------------------------------------------------------------------------- 1 | import pytest as pytest 2 | 3 | from common.exceptions import NotFoundException 4 | from features.todo.repository.todo_repository_interface import TodoRepositoryInterface 5 | from features.todo.use_cases.delete_todo_by_id import ( 6 | DeleteTodoByIdResponse, 7 | delete_todo_use_case, 8 | ) 9 | 10 | 11 | def test_delete_todo_should_return_success(todo_repository: TodoRepositoryInterface): 12 | id = "dh2109" 13 | result: DeleteTodoByIdResponse = delete_todo_use_case(id=id, user_id="xyz", todo_repository=todo_repository) 14 | assert result.success 15 | 16 | 17 | def test_delete_todo_should_return_not_success(todo_repository: TodoRepositoryInterface): 18 | id = "unknown" 19 | with pytest.raises(NotFoundException): 20 | delete_todo_use_case(id=id, user_id="xyz", todo_repository=todo_repository) 21 | -------------------------------------------------------------------------------- /web/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "files": { 4 | "ignore": [ 5 | "build/*", 6 | "documentation/*", 7 | "api/*", 8 | "web/src/api/generated", 9 | ".release-please-manifest.json" 10 | ] 11 | }, 12 | "javascript": { 13 | "formatter": { 14 | "indentStyle": "space", 15 | "indentWidth": 2, 16 | "quoteStyle": "single", 17 | "trailingCommas": "es5", 18 | "lineWidth": 119, 19 | "semicolons": "asNeeded" 20 | } 21 | }, 22 | "json": { 23 | "formatter": { 24 | "indentStyle": "space", 25 | "indentWidth": 2 26 | } 27 | }, 28 | "linter": { 29 | "rules": { 30 | "a11y": { 31 | "useButtonType": "off" 32 | }, 33 | "correctness": { 34 | "useExhaustiveDependencies": "off", 35 | "noVoidTypeReturn": "off" 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /web/src/api/generated/core/ApiError.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do not edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | import type { ApiRequestOptions } from './ApiRequestOptions'; 6 | import type { ApiResult } from './ApiResult'; 7 | 8 | export class ApiError extends Error { 9 | public readonly url: string; 10 | public readonly status: number; 11 | public readonly statusText: string; 12 | public readonly body: any; 13 | public readonly request: ApiRequestOptions; 14 | 15 | constructor(request: ApiRequestOptions, response: ApiResult, message: string) { 16 | super(message); 17 | 18 | this.name = 'ApiError'; 19 | this.url = response.url; 20 | this.status = response.status; 21 | this.statusText = response.statusText; 22 | this.body = response.body; 23 | this.request = request; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /api/src/tests/unit/features/todo/use_cases/test_get_todo_by_id.py: -------------------------------------------------------------------------------- 1 | import pytest as pytest 2 | 3 | from common.exceptions import NotFoundException 4 | from features.todo.repository.todo_repository_interface import TodoRepositoryInterface 5 | from features.todo.use_cases.get_todo_by_id import ( 6 | GetTodoByIdResponse, 7 | get_todo_by_id_use_case, 8 | ) 9 | 10 | 11 | def test_get_todo_by_id_should_return_todo(todo_repository: TodoRepositoryInterface, todo_test_data: dict[str, dict]): 12 | id = "dh2109" 13 | todo: GetTodoByIdResponse = get_todo_by_id_use_case(id, user_id="xyz", todo_repository=todo_repository) 14 | assert todo.title == todo_test_data[id]["title"] 15 | 16 | 17 | def test_get_todo_by_id_should_throw_todo_not_found_error(todo_repository: TodoRepositoryInterface): 18 | id = "unknown" 19 | with pytest.raises(NotFoundException): 20 | get_todo_by_id_use_case(id, user_id="xyz", todo_repository=todo_repository) 21 | -------------------------------------------------------------------------------- /documentation/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | Using SSH: 30 | 31 | ``` 32 | $ USE_SSH=true yarn deploy 33 | ``` 34 | 35 | Not using SSH: 36 | 37 | ``` 38 | $ GIT_USER= yarn deploy 39 | ``` 40 | 41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 42 | -------------------------------------------------------------------------------- /documentation/docs/contribute/development-guide/coding/extending-the-api/adding-data-providers/03-repositories.md: -------------------------------------------------------------------------------- 1 | # Repositories 2 | 3 | Concrete implementations of repository interfaces. A repository takes entities and returns entities, while hiding 4 | storage details. It can work against local, remote, data services or third party services. 5 | 6 | ```mdx-code-block 7 | import CodeBlock from '@theme/CodeBlock'; 8 | 9 | import TodoRepository from '!!raw-loader!@site/../api/src/features/todo/repository/todo_repository.py'; 10 | 11 | {TodoRepository} 12 | ``` 13 | 14 | ## Testing repositories 15 | 16 | Use the `test_client` fixture as input to TodoRepository. The `test_client` fixture are using the mongomock instead of 17 | real database. 18 | 19 | ```mdx-code-block 20 | import Test from '!!raw-loader!@site/../api/src/tests/unit/features/todo/repository/test_todo_repository.py'; 21 | 22 | {Test} 23 | ``` 24 | -------------------------------------------------------------------------------- /api/src/features/todo/use_cases/get_todo_by_id.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | from pydantic import BaseModel 4 | 5 | from common.exceptions import MissingPrivilegeException 6 | from features.todo.entities.todo_item import TodoItem 7 | from features.todo.repository.todo_repository_interface import TodoRepositoryInterface 8 | 9 | 10 | class GetTodoByIdResponse(BaseModel): 11 | id: str 12 | title: str 13 | is_completed: bool = False 14 | 15 | @staticmethod 16 | def from_entity(todo_item: TodoItem) -> "GetTodoByIdResponse": 17 | return GetTodoByIdResponse(id=todo_item.id, title=todo_item.title, is_completed=todo_item.is_completed) 18 | 19 | 20 | def get_todo_by_id_use_case(id: str, user_id: str, todo_repository: TodoRepositoryInterface) -> GetTodoByIdResponse: 21 | todo_item = todo_repository.get(id) 22 | if todo_item.user_id != user_id: 23 | raise MissingPrivilegeException 24 | return GetTodoByIdResponse.from_entity(cast(TodoItem, todo_item)) 25 | -------------------------------------------------------------------------------- /documentation/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | // @ts-check 13 | 14 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 15 | const sidebars = { 16 | // By default, Docusaurus generates a sidebar from the docs folder structure 17 | about: [{type: 'autogenerated', dirName: 'about'}], 18 | contribute: [{type: 'autogenerated', dirName: 'contribute'}], 19 | 20 | // But you can create a sidebar manually 21 | /* 22 | tutorialSidebar: [ 23 | 'intro', 24 | 'hello', 25 | { 26 | type: 'category', 27 | label: 'Tutorial', 28 | items: ['tutorial-basics/create-a-document'], 29 | }, 30 | ], 31 | */ 32 | }; 33 | 34 | module.exports = sidebars; 35 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Security Policy 2 | If you discover a security vulnerability in this project, please follow these steps to responsibly disclose it: 3 | 4 | 1. **Do not** create a public GitHub issue for the vulnerability. 5 | 2. Follow our guideline for Responsible Disclosure Policy at [https://www.equinor.com/about-us/csirt](https://www.equinor.com/about-us/csirt) to report the issue 6 | 7 | The following information will help us triage your report more quickly: 8 | 9 | - Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 10 | - Full paths of source file(s) related to the manifestation of the issue 11 | - The location of the affected source code (tag/branch/commit or direct URL) 12 | - Any special configuration required to reproduce the issue 13 | - Step-by-step instructions to reproduce the issue 14 | - Proof-of-concept or exploit code (if possible) 15 | - Impact of the issue, including how an attacker might exploit the issue 16 | 17 | We prefer all communications to be in English. 18 | -------------------------------------------------------------------------------- /api/src/tests/unit/features/todo/entities/test_todo_item.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from features.todo.entities.todo_item import TodoItem 4 | 5 | 6 | def test_todo_item_init(): 7 | id = str(uuid.uuid4()) 8 | todo = TodoItem(id=id, title="title 1", is_completed=False, user_id="xyz") 9 | assert todo.id == id 10 | assert todo.title == "title 1" 11 | assert not todo.is_completed 12 | 13 | 14 | def test_todo_item_from_dict(): 15 | id = str(uuid.uuid4()) 16 | init_dict = {"id": id, "title": "title 1", "is_completed": False, "user_id": "xyz"} 17 | todo = TodoItem.from_dict(init_dict) 18 | 19 | assert todo.id == id 20 | assert todo.title == "title 1" 21 | assert not todo.is_completed 22 | 23 | 24 | def test_todo_item_comparison(): 25 | id = str(uuid.uuid4()) 26 | init_dict = {"id": id, "title": "title 1", "is_completed": False, "user_id": "xyz"} 27 | todo1 = TodoItem.from_dict(init_dict) 28 | todo2 = TodoItem.from_dict(init_dict) 29 | 30 | assert todo1 == todo2 31 | -------------------------------------------------------------------------------- /documentation/docs/contribute/development-guide/04-upgrading.md: -------------------------------------------------------------------------------- 1 | # Upgrading 2 | 3 | ## Packages 4 | :::info 5 | 6 | Remember to restart! 7 | 8 | Any changes you make to these files will only come into effect after you restart the 9 | server. If you run the application using containers, 10 | you need to do `docker compose build` and then `docker compose up` to get the changes. 11 | 12 | ::: 13 | 14 | ### API dependencies 15 | First, change directory to the `/api` directory: 16 | 17 | ```shell 18 | cd /api 19 | ``` 20 | 21 | To add a new dependency, use the following command. If you only want to add it to the `dev` dependency group, add the `--dev` option. 22 | ```shell 23 | uv add [--dev] 24 | ``` 25 | 26 | To remove a dependency, use the following command 27 | ```shell 28 | uv remove 29 | ``` 30 | 31 | To update your environment, run 32 | ```shell 33 | uv sync --dev 34 | ``` 35 | 36 | ### Web dependencies 37 | 38 | ```shell 39 | cd web/ 40 | # Add or remove package to package.json 41 | yarn install 42 | ``` 43 | -------------------------------------------------------------------------------- /web/src/api/generated/services/HealthCheckService.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do not edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | import type { CancelablePromise } from '../core/CancelablePromise'; 6 | import { OpenAPI } from '../core/OpenAPI'; 7 | import { request as __request } from '../core/request'; 8 | export class HealthCheckService { 9 | /** 10 | * Get 11 | * @returns string Successful Response 12 | * @throws ApiError 13 | */ 14 | public static getHealthCheckGet(): CancelablePromise { 15 | return __request(OpenAPI, { 16 | method: 'GET', 17 | url: '/health-check', 18 | errors: { 19 | 400: `Bad Request`, 20 | 401: `Unauthorized`, 21 | 403: `Forbidden`, 22 | 404: `Not Found`, 23 | 422: `Unprocessable Content`, 24 | 500: `Internal Server Error`, 25 | }, 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.env-template: -------------------------------------------------------------------------------- 1 | AUTH_ENABLED=1 2 | # web 3 | AUTH_SCOPE=api://8a883c98-bcee-4d12-8783-3bf1b99d83b5/access_as_user 4 | CLIENT_ID=8a883c98-bcee-4d12-8783-3bf1b99d83b5 5 | TENANT_ID= 3aa4a235-b6e2-48d5-9195-7fcf05b459b0 6 | # api 7 | OAUTH_TOKEN_ENDPOINT=https://login.microsoftonline.com/3aa4a235-b6e2-48d5-9195-7fcf05b459b0/oauth2/v2.0/token #http://localhost:8080/auth/realms/protocol/openid-connect/token 8 | OAUTH_AUTH_ENDPOINT=https://login.microsoftonline.com/3aa4a235-b6e2-48d5-9195-7fcf05b459b0/oauth2/v2.0/authorize # http://localhost:8080/auth/realms/protocol/openid-connect/auth 9 | OAUTH_WELL_KNOWN=https://login.microsoftonline.com/3aa4a235-b6e2-48d5-9195-7fcf05b459b0/v2.0/.well-known/openid-configuration # http://localhost:8080/auth/realms/.well-known/openid-configuration 10 | OAUTH_AUDIENCE=api://8a883c98-bcee-4d12-8783-3bf1b99d83b5 #if using azure ad, audience is the azure client id 11 | SECRET_KEY=very_secret_key 12 | # database 13 | MONGODB_DATABASE=test 14 | MONGODB_USERNAME=root 15 | MONGODB_PASSWORD=mongodb 16 | MONGODB_HOSTNAME=db 17 | MONGODB_PORT=27017 18 | -------------------------------------------------------------------------------- /web/vite.config.mts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import { defineConfig } from 'vite' 3 | import checker from 'vite-plugin-checker' 4 | import csp from 'vite-plugin-csp-guard' 5 | import svgrPlugin from 'vite-plugin-svgr' 6 | import viteTsConfigPaths from 'vite-tsconfig-paths' 7 | 8 | export default defineConfig({ 9 | plugins: [ 10 | checker({ 11 | typescript: true, 12 | }), 13 | react(), 14 | viteTsConfigPaths(), 15 | svgrPlugin(), 16 | csp({ 17 | dev: { 18 | run: false, 19 | }, 20 | policy: { 21 | 'default-src': ["'self'"], 22 | 'font-src': ["'self'", 'https://*.equinor.com'], 23 | 'style-src': ["'self'", "'unsafe-inline'", 'https://*.equinor.com'], 24 | 'connect-src': ["'self'", 'https://*.microsoftonline.com', 'http:', ' https:'], 25 | }, 26 | build: { 27 | sri: true, 28 | }, 29 | override: true, 30 | }), 31 | ], 32 | server: { 33 | port: 3000, 34 | host: true, 35 | }, 36 | build: { 37 | outDir: 'build', 38 | }, 39 | }) 40 | -------------------------------------------------------------------------------- /web/src/api/generated/services/WhoamiService.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do not edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | import type { User } from '../models/User'; 6 | import type { CancelablePromise } from '../core/CancelablePromise'; 7 | import { OpenAPI } from '../core/OpenAPI'; 8 | import { request as __request } from '../core/request'; 9 | export class WhoamiService { 10 | /** 11 | * Get Information On Authenticated User 12 | * @returns User Successful Response 13 | * @throws ApiError 14 | */ 15 | public static whoami(): CancelablePromise { 16 | return __request(OpenAPI, { 17 | method: 'GET', 18 | url: '/whoami', 19 | errors: { 20 | 400: `Bad Request`, 21 | 401: `Unauthorized`, 22 | 403: `Forbidden`, 23 | 404: `Not Found`, 24 | 422: `Unprocessable Content`, 25 | 500: `Internal Server Error`, 26 | }, 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /api/src/tests/integration/common/test_exception_handler.py: -------------------------------------------------------------------------------- 1 | from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY 2 | from starlette.testclient import TestClient 3 | 4 | 5 | def test_exception_handler_validation_error(test_app: TestClient): 6 | response = test_app.post("/todos", json={"title": 1}) 7 | response.json() 8 | 9 | assert response.status_code == HTTP_422_UNPROCESSABLE_ENTITY 10 | assert { 11 | "status": 422, 12 | "type": "RequestValidationError", 13 | "message": "The received values are invalid", 14 | "debug": "The received values are invalid according to the endpoints model definition", 15 | "extra": { 16 | "detail": [ 17 | { 18 | "type": "string_type", 19 | "loc": ["body", "title"], 20 | "msg": "Input should be a valid string", 21 | "input": 1, 22 | "url": "https://errors.pydantic.dev/2.1.2/v/string_type", 23 | } 24 | ], 25 | "body": {"title": 1}, 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /api/Dockerfile: -------------------------------------------------------------------------------- 1 | # First, build the application in the `/app` directory 2 | FROM --platform=linux/amd64 ghcr.io/astral-sh/uv:bookworm-slim AS uv-base 3 | ENV UV_COMPILE_BYTECODE=1 4 | ENV UV_LINK_MODE=copy 5 | 6 | # Configure the Python directory so it is consistent 7 | ENV UV_PYTHON_INSTALL_DIR=/python 8 | 9 | # Only use the managed Python version 10 | ENV UV_PYTHON_PREFERENCE=only-managed 11 | 12 | RUN uv python install 3.13 13 | 14 | FROM uv-base AS base 15 | # Install Python before the project for caching 16 | 17 | WORKDIR /app 18 | RUN --mount=type=cache,target=/root/.cache/uv \ 19 | --mount=type=bind,source=uv.lock,target=uv.lock \ 20 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 21 | uv sync --locked --no-install-project --no-dev 22 | COPY . /app 23 | 24 | # Place executables in the environment at the front of the path 25 | ENV PATH="/app/.venv/bin:$PATH" 26 | WORKDIR /app/src 27 | EXPOSE 5000 28 | CMD ["/app/src/init.sh", "api"] 29 | 30 | 31 | FROM base AS development 32 | RUN --mount=type=cache,target=/root/.cache/uv \ 33 | uv sync --locked --dev 34 | 35 | FROM base AS prod 36 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directories: 5 | - "/web" 6 | - "/documentation" 7 | schedule: 8 | interval: "monthly" # when template is used, recommended interval is "weekly" 9 | groups: 10 | web: 11 | update-types: 12 | - "minor" 13 | - "patch" 14 | 15 | - package-ecosystem: "pip" 16 | directory: "/api" 17 | schedule: 18 | interval: "monthly" # when template is used, recommended interval is "weekly" 19 | groups: 20 | api: 21 | update-types: 22 | - "minor" 23 | - "patch" 24 | 25 | - package-ecosystem: "docker" 26 | directories: 27 | - "/web" 28 | - "/api" 29 | schedule: 30 | interval: "monthly" # when template is used, recommended interval is "weekly" 31 | groups: 32 | dockerfile: 33 | update-types: 34 | - "minor" 35 | - "patch" 36 | 37 | - package-ecosystem: "github-actions" 38 | directory: "/" 39 | schedule: 40 | interval: "monthly" # when template is used, recommended interval is "weekly" 41 | -------------------------------------------------------------------------------- /web/src/api/generated/core/OpenAPI.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do not edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | import type { ApiRequestOptions } from './ApiRequestOptions'; 6 | 7 | type Resolver = (options: ApiRequestOptions) => Promise; 8 | type Headers = Record; 9 | 10 | export type OpenAPIConfig = { 11 | BASE: string; 12 | VERSION: string; 13 | WITH_CREDENTIALS: boolean; 14 | CREDENTIALS: 'include' | 'omit' | 'same-origin'; 15 | TOKEN?: string | Resolver | undefined; 16 | USERNAME?: string | Resolver | undefined; 17 | PASSWORD?: string | Resolver | undefined; 18 | HEADERS?: Headers | Resolver | undefined; 19 | ENCODE_PATH?: ((path: string) => string) | undefined; 20 | }; 21 | 22 | export const OpenAPI: OpenAPIConfig = { 23 | BASE: '', 24 | VERSION: '1.4.0', 25 | WITH_CREDENTIALS: false, 26 | CREDENTIALS: 'include', 27 | TOKEN: undefined, 28 | USERNAME: undefined, 29 | PASSWORD: undefined, 30 | HEADERS: undefined, 31 | ENCODE_PATH: undefined, 32 | }; 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Equinor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /documentation/docs/about/02-overview.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | ## Getting started 4 | 5 | In order to start using this template for your own project, go to [equinor/template-fastapi-react](https://github.com/equinor/template-fastapi-react) and click the `Use this template` button to create a copy. 6 | 7 | Next, go-to the instructions on how-to [run locally](running/01-prerequisites.md) . 8 | 9 | For setting up a development environment, go to the [development guide](../contribute/development-guide/01-setup.md). Next, to start coding and extending the template see the [coding section](../contribute/development-guide/coding/01-architecture.md). 10 | 11 | For starting contributing to the template see [contribute section](../contribute/01-how-to-start-contributing.md). 12 | 13 | ## Project structure 14 | 15 | Here’s how the app is organized. 16 | 17 | ``` 18 | ├── api/ - backend code 19 | │── web/ - frontend code 20 | │── documentation/ - documentation 21 | ├── nginx/ - reverse proxy 22 | ├── .env-template - template for environment variables 23 | ├── docker-compose.override.yml - for running locally 24 | ├── docker-compose.yml - common docker compose settings 25 | └── ... 26 | ``` 27 | -------------------------------------------------------------------------------- /documentation/docs/contribute/development-guide/coding/extending-the-api/02-adding-entities.md: -------------------------------------------------------------------------------- 1 | # Adding entities 2 | 3 | Entities form the domain model of the application. 4 | 5 | An entity can be an object with methods, or it can be a set of data structures and functions. It should be a regular 6 | class, a dataclass, or a value object (if all the properties are the same, two objects are identical). Entities hold 7 | data (state) and logic reusable for various applications. 8 | 9 | ```mdx-code-block 10 | import CodeBlock from '@theme/CodeBlock'; 11 | import TodoItem from '!!raw-loader!@site/../api/src/features/todo/entities/todo_item.py'; 12 | 13 | {TodoItem} 14 | ``` 15 | 16 | :::info 17 | Entities must not depend on anything, except possibly other entities. 18 | 19 | Entities should be the most stable code within your application. 20 | 21 | Entities should not be affected by any change external to them. 22 | ::: 23 | 24 | ## Testing entities 25 | 26 | ```mdx-code-block 27 | import Test from '!!raw-loader!@site/../api/src/tests/unit/features/todo/entities/test_todo_item.py'; 28 | 29 | {Test} 30 | ``` 31 | -------------------------------------------------------------------------------- /documentation/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary: #2e8555; 10 | --ifm-color-primary-dark: #29784c; 11 | --ifm-color-primary-darker: #277148; 12 | --ifm-color-primary-darkest: #205d3b; 13 | --ifm-color-primary-light: #33925d; 14 | --ifm-color-primary-lighter: #359962; 15 | --ifm-color-primary-lightest: #3cad6e; 16 | --ifm-code-font-size: 95%; 17 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); 18 | } 19 | 20 | /* For readability concerns, you should choose a lighter palette in dark mode. */ 21 | [data-theme='dark'] { 22 | --ifm-color-primary: #25c2a0; 23 | --ifm-color-primary-dark: #21af90; 24 | --ifm-color-primary-darker: #1fa588; 25 | --ifm-color-primary-darkest: #1a8870; 26 | --ifm-color-primary-light: #29d5b0; 27 | --ifm-color-primary-lighter: #32d8b4; 28 | --ifm-color-primary-lightest: #4fddbf; 29 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); 30 | } 31 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "release-type": "simple", 3 | "changelog-sections": [ 4 | { "type": "feat", "section": "Features", "hidden": false }, 5 | { "type": "feature", "section": "Features", "hidden": false }, 6 | { "type": "fix", "section": "Bug Fixes", "hidden": false }, 7 | { "type": "perf", "section": "Performance Improvements", "hidden": false }, 8 | { "type": "revert", "section": "Reverts", "hidden": false }, 9 | { "type": "docs", "section": "Documentation", "hidden": false }, 10 | { "type": "style", "section": "Styles", "hidden": false }, 11 | { "type": "chore", "section": "Miscellaneous Chores", "hidden": false }, 12 | { "type": "refactor", "section": "Code Refactoring", "hidden": false }, 13 | { "type": "test", "section": "Tests", "hidden": false }, 14 | { "type": "build", "section": "Build System", "hidden": false }, 15 | { "type": "ci", "section": "Continuous Integration", "hidden": false } 16 | ], 17 | "extra-files": ["src/app.py"], 18 | "packages": { 19 | ".": { 20 | "package-name": "template-fastapi-react", 21 | "changelog-path": "documentation/docs/changelog/changelog.md" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /api/src/features/todo/use_cases/update_todo.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | from common.exceptions import MissingPrivilegeException 4 | from features.todo.entities.todo_item import TodoItem 5 | from features.todo.repository.todo_repository_interface import TodoRepositoryInterface 6 | 7 | 8 | class UpdateTodoRequest(BaseModel): 9 | title: str = Field( 10 | "", 11 | title="The title of the item", 12 | max_length=300, 13 | min_length=1, 14 | ) 15 | is_completed: bool 16 | 17 | 18 | class UpdateTodoResponse(BaseModel): 19 | success: bool 20 | 21 | 22 | def update_todo_use_case( 23 | id: str, 24 | data: UpdateTodoRequest, 25 | user_id: str, 26 | todo_repository: TodoRepositoryInterface, 27 | ) -> UpdateTodoResponse: 28 | todo_item = todo_repository.get(id) 29 | if todo_item.user_id != user_id: 30 | raise MissingPrivilegeException 31 | 32 | updated_todo_item = TodoItem(id=todo_item.id, title=data.title, is_completed=data.is_completed, user_id=user_id) 33 | if todo_repository.update(updated_todo_item): 34 | return UpdateTodoResponse(success=True) 35 | return UpdateTodoResponse(success=False) 36 | -------------------------------------------------------------------------------- /web/nginx/sites-available/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 8080; 3 | server_name localhost; 4 | client_max_body_size 2G; 5 | 6 | # security 7 | # NOTE: This also need to be included in the location block IF there are other headers being set at that level 8 | include /etc/nginx/config/security.conf; 9 | 10 | # logs 11 | access_log /dev/stdout combined; 12 | error_log /dev/stdout; 13 | 14 | # compression 15 | gzip on; 16 | gzip_vary on; 17 | gzip_proxied any; 18 | gzip_comp_level 6; 19 | gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; 20 | 21 | location /api/ { 22 | proxy_pass http://api:5000/; 23 | 24 | include /etc/nginx/config/general.conf; 25 | include /etc/nginx/config/proxy.conf; 26 | include /etc/nginx/config/websocket.conf; 27 | } 28 | location / { 29 | include /etc/nginx/environments/*.conf; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /web/src/api/generated/index.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do not edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | export { ApiError } from './core/ApiError'; 6 | export { CancelablePromise, CancelError } from './core/CancelablePromise'; 7 | export { OpenAPI } from './core/OpenAPI'; 8 | export type { OpenAPIConfig } from './core/OpenAPI'; 9 | 10 | export { AccessLevel } from './models/AccessLevel'; 11 | export type { AddTodoRequest } from './models/AddTodoRequest'; 12 | export type { AddTodoResponse } from './models/AddTodoResponse'; 13 | export type { DeleteTodoByIdResponse } from './models/DeleteTodoByIdResponse'; 14 | export type { ErrorResponse } from './models/ErrorResponse'; 15 | export type { GetTodoAllResponse } from './models/GetTodoAllResponse'; 16 | export type { GetTodoByIdResponse } from './models/GetTodoByIdResponse'; 17 | export type { UpdateTodoRequest } from './models/UpdateTodoRequest'; 18 | export type { UpdateTodoResponse } from './models/UpdateTodoResponse'; 19 | export type { User } from './models/User'; 20 | 21 | export { HealthCheckService } from './services/HealthCheckService'; 22 | export { TodoService } from './services/TodoService'; 23 | export { WhoamiService } from './services/WhoamiService'; 24 | -------------------------------------------------------------------------------- /api/src/common/responses.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from common.exceptions import ( 4 | ApplicationException, 5 | BadRequestException, 6 | ErrorResponse, 7 | MissingPrivilegeException, 8 | NotFoundException, 9 | ValidationException, 10 | ) 11 | 12 | responses: dict[int | str, dict[str, Any]] = { 13 | 400: {"model": ErrorResponse, "content": {"application/json": {"example": BadRequestException().to_dict()}}}, 14 | 401: { 15 | "model": ErrorResponse, 16 | "content": { 17 | "application/json": { 18 | "example": ErrorResponse( 19 | status=401, type="UnauthorizedException", message="Token validation failed" 20 | ).model_dump() 21 | } 22 | }, 23 | }, 24 | 403: { 25 | "model": ErrorResponse, 26 | "content": {"application/json": {"example": MissingPrivilegeException().to_dict()}}, 27 | }, 28 | 404: {"model": ErrorResponse, "content": {"application/json": {"example": NotFoundException().to_dict()}}}, 29 | 422: {"model": ErrorResponse, "content": {"application/json": {"example": ValidationException().to_dict()}}}, 30 | 500: {"model": ErrorResponse, "content": {"application/json": {"example": ApplicationException().to_dict()}}}, 31 | } 32 | -------------------------------------------------------------------------------- /api/src/features/todo/use_cases/get_todo_all.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from common.logger import logger 4 | from common.telemetry import tracer 5 | from features.todo.entities.todo_item import TodoItem 6 | from features.todo.repository.todo_repository_interface import TodoRepositoryInterface 7 | 8 | 9 | class GetTodoAllResponse(BaseModel): 10 | id: str 11 | title: str 12 | is_completed: bool 13 | 14 | @staticmethod 15 | def from_entity(todo_item: TodoItem) -> "GetTodoAllResponse": 16 | return GetTodoAllResponse(id=todo_item.id, title=todo_item.title, is_completed=todo_item.is_completed) 17 | 18 | 19 | # Telemetry example: Initialize a span that will be used to log telemetry data 20 | @tracer.start_as_current_span("get_todo_all_use_case") # type: ignore 21 | def get_todo_all_use_case( 22 | user_id: str, 23 | todo_repository: TodoRepositoryInterface, 24 | ) -> list[GetTodoAllResponse]: 25 | # Telemetry example 26 | logger.info( 27 | f"Get todos for user: {user_id}" 28 | ) # This log message will be logged within the span context defined above 29 | return [ 30 | GetTodoAllResponse.from_entity(todo_item) 31 | for todo_item in todo_repository.get_all() 32 | if todo_item.user_id == user_id 33 | ] 34 | -------------------------------------------------------------------------------- /api/src/data_providers/clients/client_interface.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Any, Generic, TypeVar 3 | 4 | # Type definition for Model 5 | M = TypeVar("M") 6 | 7 | # Type definition for Unique Id 8 | K = TypeVar("K") 9 | 10 | # Type definition for filter 11 | FilterDict = dict[str, Any] 12 | 13 | 14 | class ClientInterface(Generic[M, K]): 15 | @abstractmethod 16 | def create(self, instance: M) -> M: 17 | pass 18 | 19 | @abstractmethod 20 | def delete(self, id: K) -> bool: 21 | pass 22 | 23 | @abstractmethod 24 | def get(self, id: K) -> M: 25 | pass 26 | 27 | @abstractmethod 28 | def list_collection(self) -> list[M]: 29 | pass 30 | 31 | @abstractmethod 32 | def update(self, id: K, instance: M) -> M: 33 | pass 34 | 35 | @abstractmethod 36 | def insert_many(self, instances: list[M]) -> None: 37 | pass 38 | 39 | @abstractmethod 40 | def delete_many(self, filter: FilterDict) -> None: 41 | pass 42 | 43 | @abstractmethod 44 | def find(self, filter: FilterDict) -> M: 45 | pass 46 | 47 | @abstractmethod 48 | def find_one(self, filter: FilterDict) -> M | None: 49 | pass 50 | 51 | @abstractmethod 52 | def delete_collection(self) -> None: 53 | pass 54 | -------------------------------------------------------------------------------- /documentation/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS files with the .module.css suffix will be treated as CSS modules 3 | * and scoped locally. 4 | */ 5 | 6 | .heroBanner { 7 | padding: 4rem 0; 8 | text-align: center; 9 | position: relative; 10 | overflow: hidden; 11 | } 12 | 13 | @media screen and (max-width: 996px) { 14 | .heroBanner { 15 | padding: 2rem; 16 | } 17 | } 18 | 19 | .buttons { 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | } 24 | 25 | .description { 26 | text-align: center; 27 | font-size: 24px; 28 | margin: 32px auto; 29 | width: 65%; 30 | } 31 | 32 | .features { 33 | display: flex; 34 | justify-content: center; 35 | flex-wrap: wrap; 36 | margin: 10px auto 80px; 37 | } 38 | 39 | 40 | .feature { 41 | display: flex; 42 | justify-content: center; 43 | align-items: center; 44 | --feature-size: 220px; 45 | height: var(--feature-size); 46 | width: var(--feature-size); 47 | margin: 10px; 48 | padding: 12px; 49 | 50 | font-size: 16px; 51 | font-weight: bold; 52 | text-align: center; 53 | 54 | transition: all 0.25s; 55 | background: rgba(255, 255, 255, 0.08); 56 | border-radius: 16px; 57 | box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1); 58 | border: 1px solid rgba(255, 255, 255, 0.18); 59 | } 60 | 61 | .feature:hover { 62 | background: rgba(255, 255, 255, 0.12); 63 | } 64 | -------------------------------------------------------------------------------- /web/src/common/components/VersionText.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from '@equinor/eds-core-react' 2 | import axios, { type AxiosError, type AxiosResponse } from 'axios' 3 | import { useEffect, useState } from 'react' 4 | 5 | type CommitInfo = { 6 | hash: string 7 | date: string 8 | refs: string 9 | } 10 | 11 | const useCommitInfo = () => { 12 | const [commitInfo, setCommitInfo] = useState({ 13 | hash: '', 14 | date: '', 15 | refs: '', 16 | }) 17 | 18 | useEffect(() => { 19 | const fetchVersionFile = () => 20 | axios 21 | .get('version.txt') 22 | .then((res: AxiosResponse) => Object.fromEntries(res.data.split('\n').map((line) => line.split(': ')))) 23 | .catch((error: AxiosError) => { 24 | throw new Error(`Could not read version file, ${error.response?.data ?? error}`) 25 | }) 26 | fetchVersionFile().then((commitInfo) => setCommitInfo(commitInfo)) 27 | }, []) 28 | 29 | return commitInfo 30 | } 31 | 32 | export const VersionText = () => { 33 | const commitInfo = useCommitInfo() 34 | 35 | return ( 36 |

37 | Version:{' '} 38 | <> 39 | 40 | {commitInfo.refs === '' ? commitInfo.hash : commitInfo.refs} 41 | {' '} 42 | {commitInfo.date} 43 | 44 |

45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /web/src/features/todos/todo-list/TodoItem.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Typography } from '@equinor/eds-core-react' 2 | import { done, remove_outlined, undo } from '@equinor/eds-icons' 3 | import type { AddTodoResponse } from '../../../api/generated' 4 | import IconButton from '../../../common/components/IconButton' 5 | import { useTodoAPI } from '../../../hooks/useTodoAPI' 6 | import { StyledTodoItemTitle } from './TodoItem.styled' 7 | 8 | const TodoItem = ({ todo }: { todo: AddTodoResponse }) => { 9 | const { toggleTodoItem, removeTodoItem } = useTodoAPI() 10 | 11 | async function toggle() { 12 | await toggleTodoItem(todo) 13 | } 14 | 15 | async function remove() { 16 | await removeTodoItem(todo) 17 | } 18 | 19 | return ( 20 | 21 | 22 | 23 | 24 | {todo.title} 25 | 26 | {todo.is_completed ? 'Done' : 'Todo'} 27 | 28 | 33 | 34 | 35 | 36 | ) 37 | } 38 | 39 | export default TodoItem 40 | -------------------------------------------------------------------------------- /api/src/features/todo/use_cases/add_todo.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | from features.todo.entities.todo_item import TodoItem 6 | from features.todo.repository.todo_repository_interface import TodoRepositoryInterface 7 | 8 | 9 | class AddTodoRequest(BaseModel): 10 | title: str = Field( 11 | title="The title of the item", 12 | max_length=300, 13 | min_length=1, 14 | json_schema_extra={ 15 | "examples": ["Read about clean architecture"], 16 | }, 17 | ) 18 | 19 | 20 | class AddTodoResponse(BaseModel): 21 | id: str = Field( 22 | json_schema_extra={ 23 | "examples": ["vytxeTZskVKR7C7WgdSP3d"], 24 | } 25 | ) 26 | title: str = Field( 27 | json_schema_extra={ 28 | "examples": ["Read about clean architecture"], 29 | } 30 | ) 31 | is_completed: bool = False 32 | 33 | @staticmethod 34 | def from_entity(todo_item: TodoItem) -> "AddTodoResponse": 35 | return AddTodoResponse(id=todo_item.id, title=todo_item.title, is_completed=todo_item.is_completed) 36 | 37 | 38 | def add_todo_use_case( 39 | data: AddTodoRequest, 40 | user_id: str, 41 | todo_repository: TodoRepositoryInterface, 42 | ) -> AddTodoResponse: 43 | todo_item = TodoItem(id=str(uuid.uuid4()), title=data.title, user_id=user_id) 44 | todo_repository.create(todo_item) 45 | return AddTodoResponse.from_entity(todo_item) 46 | -------------------------------------------------------------------------------- /documentation/docs/contribute/development-guide/03-testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | ## API 4 | 5 | ```mdx-code-block 6 | import TabItem from '@theme/TabItem'; 7 | import Tabs from '@theme/Tabs'; 8 | ``` 9 | 10 | The application has two types of API tests: unit tests and integration tests. 11 | 12 | ### Unit tests 13 | 14 | You will find unit tests under `src/tests/unit`. 15 | 16 | 17 | 18 | 19 | ```shell 20 | docker compose run --rm api pytest 21 | ``` 22 | 23 | 24 | 25 | 26 | ```shell 27 | cd api/ 28 | pytest 29 | ``` 30 | 31 | 32 | 33 | 34 | 35 | As a general rule, unit tests should not have any external dependencies - especially on the file system. 36 | 37 | ### Integration tests 38 | 39 | The integrations tests can be found under `src/tests/integration`. 40 | 41 | To run integration tests add `--integration` as argument for pytest. 42 | 43 | These tests depends on mongodb and that it's running. 44 | 45 | ## Web 46 | 47 | ### Unit tests 48 | 49 | 50 | 51 | 52 | ```shell 53 | docker compose run --rm web yarn test 54 | ``` 55 | 56 | 57 | 58 | 59 | ```shell 60 | cd web/ 61 | yarn test 62 | ``` 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /documentation/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "documentation", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@akebifiky/remark-simple-plantuml": "^1.0.2", 19 | "@docusaurus/core": "3.8.1", 20 | "@docusaurus/preset-classic": "3.8.1", 21 | "@mdx-js/react": "^3.1.1", 22 | "clsx": "^2.1.1", 23 | "mdx-mermaid": "^2.0.3", 24 | "mermaid": "^11.10.1", 25 | "prism-react-renderer": "^2.4.1", 26 | "react": "^19.1.1", 27 | "react-dom": "^19.1.1" 28 | }, 29 | "devDependencies": { 30 | "@docusaurus/module-type-aliases": "3.8.1", 31 | "@tsconfig/docusaurus": "^2.0.3", 32 | "raw-loader": "^4.0.2", 33 | "typescript": "^5.9.2" 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.5%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | }, 47 | "engines": { 48 | "node": ">=16.14" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Progress, Typography } from '@equinor/eds-core-react' 2 | import { useContext } from 'react' 3 | import { AuthContext } from 'react-oauth2-code-pkce' 4 | import { RouterProvider } from 'react-router-dom' 5 | import styled from 'styled-components' 6 | import { OpenAPI } from './api/generated' 7 | import Header from './common/components/Header' 8 | import { router } from './router' 9 | 10 | const hasAuthConfig = import.meta.env.VITE_AUTH === '1' 11 | 12 | const CenterContainer = styled.div` 13 | display: flex; 14 | gap: 12px; 15 | flex-direction: column; 16 | justify-content: center; 17 | align-items: center; 18 | width: 100vw; 19 | height: 100vh; 20 | ` 21 | 22 | function App() { 23 | const { token, error, logIn, loginInProgress } = useContext(AuthContext) 24 | 25 | OpenAPI.TOKEN = token 26 | 27 | if (hasAuthConfig && error) { 28 | return {error} 29 | } 30 | 31 | if (hasAuthConfig && loginInProgress) { 32 | return ( 33 | 34 | Login in progress. 35 | 36 | 37 | ) 38 | } 39 | 40 | if (hasAuthConfig && !token) { 41 | return ( 42 | 43 | 44 | 45 | ) 46 | } 47 | 48 | return ( 49 | <> 50 |
51 | 52 | 53 | ) 54 | } 55 | 56 | export default App 57 | -------------------------------------------------------------------------------- /.github/workflows/rollback.yaml: -------------------------------------------------------------------------------- 1 | name: "Rollback prod to an older release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | release-tag: 6 | description: "GitHub release tag. Must match a Docker image tag" 7 | required: true 8 | type: string 9 | 10 | permissions: 11 | contents: read 12 | id-token: write 13 | packages: write 14 | 15 | env: 16 | IMAGE_REGISTRY: ghcr.io 17 | API_IMAGE: ghcr.io/equinor/neqsimapi 18 | 19 | jobs: 20 | set-prod-tag: 21 | runs-on: ubuntu-latest 22 | strategy: 23 | matrix: 24 | image: 25 | [ 26 | ghcr.io/equinor/template-fastapi-react/api, 27 | ghcr.io/equinor/template-fastapi-react/web, 28 | ghcr.io/equinor/template-fastapi-react/nginx, 29 | ] 30 | steps: 31 | - name: "Login to image registry" 32 | run: echo ${{ secrets.GITHUB_TOKEN }} | docker login $IMAGE_REGISTRY -u $GITHUB_ACTOR --password-stdin 33 | 34 | - name: "Set prod tag on older image" 35 | run: | 36 | echo "Tagging ${{ matrix.image }}:${{ inputs.release-tag }} with 'production'" 37 | docker pull ${{ matrix.image }}:${{ inputs.release-tag }} 38 | docker tag ${{ matrix.image }}:${{ inputs.release-tag }} ${{ matrix.image }}:production 39 | docker push $API_IMAGE:production 40 | 41 | # uncomment to apply rollback to radix 42 | # deploy: 43 | # needs: set-prod-tag 44 | # uses: ./.github/workflows/deploy-to-radix.yaml 45 | # with: 46 | # radix-environment: "prod" 47 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with TypeScript React 2 | 3 | This project was set up with [Create React App](https://github.com/facebook/create-react-app), but then ported to use [Vite](https://vitejs.dev/), which is faster and more actively maintained. 4 | 5 | * TypeScript React 6 | * Linting with eslint 7 | * Formatting with prettier 8 | * Testing with Jest and Enzyme 9 | * State management with React Context 10 | 11 | ## TypeScript React 12 | 13 | The starter project layout should look like the following: 14 | 15 | ```text 16 | web/ 17 | ├─ node_modules/ 18 | ├─ public/ 19 | ├─ src/ 20 | │ └─ ... 21 | ├─ package.json 22 | ├─ tsconfig.json 23 | ├─ tsconfig.prod.json 24 | ├─ tsconfig.test.json 25 | └─ .eslintrc.js 26 | ``` 27 | 28 | Of note: 29 | 30 | * `tsconfig.json` contains TypeScript-specific options for the project. 31 | * You can also add a `tsconfig.prod.json` and a `tsconfig.test.json` in case you want to make any tweaks to your production builds, or your test builds. 32 | * `.eslintrc.js` stores the settings for linting, [eslint](https://github.com/eslint/eslint). 33 | * `package.json` contains dependencies, as well as some shortcuts for commands we'd like to run for testing, previewing, and deploying the app. 34 | * `public` contains static assets like the HTML page we're planning to deploy to, or images. You can delete any file in this folder apart from `index.html`. 35 | * `src` contains the code. `index.tsx` is the entry-point for your file, and is mandatory. 36 | 37 | To learn React, check out the [React documentation](https://reactjs.org/). 38 | -------------------------------------------------------------------------------- /documentation/docs/about/concepts/01-task.md: -------------------------------------------------------------------------------- 1 | # Task 2 | 3 | :::info 4 | 5 | This is an example of a "concept" which is domain specific, and not related to your application. It should be replaced by relevant domain specific concepts in your documentation. Note that for some concepts, having an "Examples"-section does not make sense. Feel free to adapt the example's structure to best suit your concepts. 6 | 7 | ::: 8 | 9 | A task is piece of work which is assigned to be done by one or multiple persons. A task usually has defined limits, often referred to as the task description. 10 | 11 | In order to remember assigned tasks, they are often made note of in lists. Traditionally, these lists have been written on small notes (e.g. post-its), but in recent years there have been a large number of todo-apps developed for phones and computers, replacing its analogue predecessor. See [related concepts](#related-concepts) for more on to-do lists. 12 | 13 | Once a task is assigned to a person, the person is expected to carry out the task until completion. There is often a time-limit associated with a task, and a failure to complete the task within the time-limit might be unacceptable and as unfavorable as not completing the task at all. 14 | 15 | ## Examples 16 | 17 | 1. Many young children are given chores around the house, such as taking out the trash or cleaning their room. 18 | 2. All employees have a set of tasks to complete, which are often defined in their contract or verbally during their training. 19 | 20 | ## Related concepts 21 | 22 | - [To-do list](01-task.md) 23 | - xxx 24 | -------------------------------------------------------------------------------- /documentation/docs/contribute/03-documentation.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | This site was generated from the contents of your `documentation` folder using [Docusaurus](https://docusaurus.io/) and we host it on GitHub Pages. 4 | 5 | ## How it works 6 | 7 | From Docusaurus own documentation: 8 | > Docusaurus is a static-site generator. It builds a single-page application with fast client-side navigation, leveraging the full power of React to make your site interactive. It provides out-of-the-box documentation features but can be used to create any kind of site (personal website, product, blog, marketing landing pages, etc). 9 | 10 | While Docusaurus is rich on features, we use it mostly to host markdown pages. The main bulk of the documentation is located in `documentation/docs`. 11 | 12 | ## Publishing 13 | 14 | We are using the Github Action [`publish-docs.yaml`](https://github.com/equinor/template-fastapi-react/blob/main/.github/workflows/publish-docs.yaml) to build and publish the documentation website. This action is run every time someone pushes to the `main` branch. 15 | 16 | This will checkout the code, download the changelog from the `generate-changelog.yaml` action, and build the documentation. Then it will deploy the documentation (placed in the documentation/build/ folder) to GitHub Pages. 17 | 18 | ## Initial settings 19 | 20 | When deployed to GitHub Pages, you do need to configure your site under the settings. Pick the gh-pages branch and select either a private url or a public one. It will show you the site’s url, which should now contain your generated documentation site. 21 | 22 | ## Assets 23 | 24 | All assets files are places under `documentation/static` 25 | -------------------------------------------------------------------------------- /api/src/common/middleware.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from starlette.datastructures import MutableHeaders 4 | from starlette.types import ASGIApp, Message, Receive, Scope, Send 5 | 6 | from common.logger import logger 7 | 8 | 9 | # These middlewares are written as "pure ASGI middleware", see: https://www.starlette.io/middleware/#pure-asgi-middleware 10 | # Middleware inheriting from the "BaseHTTPMiddleware" class does not work with Starlettes BackgroundTasks. 11 | # see: https://github.com/encode/starlette/issues/919 12 | class LocalLoggerMiddleware: 13 | def __init__(self, app: ASGIApp): 14 | self.app = app 15 | 16 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 17 | if scope["type"] != "http": 18 | return await self.app(scope, receive, send) 19 | 20 | start_time = time.time() 21 | process_time = "" 22 | path = scope["path"] 23 | method = scope["method"] 24 | response: Message = {} 25 | 26 | async def inner_send(message: Message) -> None: 27 | nonlocal process_time 28 | nonlocal response 29 | if message["type"] == "http.response.start": 30 | process_time_ms = time.time() - start_time 31 | process_time = str(int(round(process_time_ms * 1000))) 32 | response = message 33 | 34 | headers = MutableHeaders(scope=message) 35 | headers.append("X-Process-Time", process_time) 36 | 37 | await send(message) 38 | 39 | await self.app(scope, receive, inner_send) 40 | logger.info(f"{method} {path} - {process_time}ms - {response['status']}") 41 | -------------------------------------------------------------------------------- /documentation/docs/contribute/development-guide/coding/extending-the-api/adding-features/02-use-cases.md: -------------------------------------------------------------------------------- 1 | # Use cases 2 | 3 | Use cases implement and encapsulate all the application business rules. 4 | 5 | If the use case wants to access a database (infrastructure layer), then the use case will use a data provider interface. The `add_todo_use_case` interacts with the infrastructure layer via `TodoRepositoryInterface`. 6 | 7 | ```mdx-code-block 8 | import CodeBlock from '@theme/CodeBlock'; 9 | 10 | import UseCase from '!!raw-loader!@site/../api/src/features/todo/use_cases/add_todo.py'; 11 | 12 | {UseCase} 13 | ``` 14 | 15 | * `Required` 16 | * Each use case needs to have its own read and write model to handle custom requests inputs and outputs for each use case. 17 | * `Optional` 18 | * A [repository interface](../adding-data-providers/02-repository-interfaces.md) describing necessary repository methods. 19 | * The use case uses [repositories](../adding-data-providers/03-repositories.md) for reading and writing to external systems like databases. 20 | 21 | :::info 22 | Changes to use cases should not impact the entities. 23 | 24 | The use-case should only know of the repository interface (abstract class) before run-time. The concrete implementation of a repository is injected (dependency injection) into the use-case at run-time. 25 | 26 | ::: 27 | 28 | ## Testing use cases 29 | 30 | Use the `todo_repository` fixture as input to use_cases. 31 | 32 | ```mdx-code-block 33 | import Test from '!!raw-loader!@site/../api/src/tests/unit/features/todo/use_cases/test_add_todo.py'; 34 | 35 | {Test} 36 | ``` 37 | -------------------------------------------------------------------------------- /web/src/common/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, TopBar, Typography } from '@equinor/eds-core-react' 2 | import { info_circle, log_out, receipt } from '@equinor/eds-icons' 3 | import { useContext, useRef, useState } from 'react' 4 | import { AuthContext } from 'react-oauth2-code-pkce' 5 | import IconButton from './IconButton' 6 | import Popover from './Popover' 7 | import { VersionText } from './VersionText' 8 | 9 | const Header = () => { 10 | const { tokenData, logOut } = useContext(AuthContext) 11 | const [isPopoverOpen, setPopoverOpen] = useState(false) 12 | const aboutRef = useRef(null) 13 | 14 | // unique_name might be azure-specific, different tokenData-fields might 15 | // be available if another OAuth-provider is used. 16 | const username = tokenData?.unique_name 17 | 18 | const togglePopover = () => setPopoverOpen(!isPopoverOpen) 19 | 20 | return ( 21 | <> 22 | 23 | 24 | 25 | Todo App 26 | 27 | 28 | {`Logged in as ${username}`} 29 | 30 | 31 | 32 | 33 | 34 | 35 |

Person of contact: Eirik Ola Aksnes (eaks@equinor.com)

36 |
37 | 38 | ) 39 | } 40 | 41 | export default Header 42 | -------------------------------------------------------------------------------- /web/generate-api-typescript-client-pre-commit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eo 3 | 4 | PATTERN="api/src/features/*" 5 | PATTERN+="|api/src/entities/*" 6 | PATTERN+="|api/src/common/*" 7 | PATTERN+="|api/src/authentication/*" 8 | 9 | CHANGED_API_FILES=() 10 | while read -r; do 11 | CHANGED_API_FILES+=("$REPLY") 12 | done < <(git diff --name-only --cached --diff-filter=ACMR | grep -E "$PATTERN") 13 | 14 | if [ ${#CHANGED_API_FILES[@]} -eq 0 ]; then 15 | echo "No changes in API relevant files. No need to generate API." 16 | exit 0 17 | fi 18 | 19 | echo "Changes detected in API relevant files. Generating API ..." 20 | # This requires the API to be running on localhost port 5000 21 | # Define the URL of the OpenAPI specification 22 | OPENAPI_URL="http://0.0.0.0:5000/openapi.json" 23 | 24 | # Use curl to fetch the OpenAPI specification and store it in a temporary file 25 | TEMP_FILE=$(mktemp) 26 | if ! curl -s "$OPENAPI_URL" >"$TEMP_FILE"; then 27 | echo "Failed to fetch the OpenAPI specification." 28 | rm "$TEMP_FILE" 29 | exit 1 30 | fi 31 | 32 | # Use grep to extract the name from the OpenAPI specification 33 | NAME=$(grep -o -s '"title": *"[^"]*"' "$TEMP_FILE" | head -1 | awk -F '"' '{print $4}') 34 | 35 | # Check if the name is empty or null 36 | if [ -z "$NAME" ]; then 37 | echo "Name of API not found in the OpenAPI specification." 38 | exit 1 39 | elif [ "$NAME" != "Template FastAPI React" ]; then 40 | echo "The openapi specification found at localhost:5000 ('$NAME') does not seem to belong to 'Template FastAPI React'" 41 | exit 1 42 | else 43 | cd web 44 | yarn openapi -i http://localhost:5000/openapi.json -o "$PWD/src/api/generated" 45 | echo "API Client successfully generated" 46 | fi 47 | 48 | # Clean up the temporary file 49 | rm "$TEMP_FILE" 50 | -------------------------------------------------------------------------------- /documentation/docs/contribute/development-guide/coding/extending-the-api/adding-features/03-securing-endpoints.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | # Securing endpoints 6 | 7 | The REST API (i.e. python FastAPI server) has access to, and is responsible for serving, data that could be private. Therefore we need to validate that the request is coming from an authenticated client. 8 | 9 | We do that in these steps; 10 | 11 | 1. Require a JWT on each request 12 | 2. Fetch the RSA public keys from the authentication server. 13 | 3. Validate the JWT signature with the auth server's public key 14 | 15 | FastAPI has system called [__dependencies__](https://fastapi.tiangolo.com/tutorial/dependencies) that is well suited for running a specific function before every request. 16 | 17 | Here is an example; 18 | 19 | ```python 20 | routes = APIRouter() 21 | app = FastAPI(title="Awesome Boilerplate") # Create the FastAPI app 22 | app.include_router(routes) # Add a route/controller to the app 23 | # Add the 'auth_with_jwt()' function as a dependency 24 | # This function will do the actual JWT validation with step 2 and 3 25 | app.include_router(routes, dependencies=[Security(auth_with_jwt)]) 26 | ``` 27 | 28 | That's it! Now every route added like this will require a successful JWT validation before the request will be processed. 29 | 30 | Dependencies can also return values, useful if you need to do some kind of __authorization__. 31 | Here is one example; 32 | 33 | ```python 34 | @router.delete("/{id}", operation_id="delete-report") 35 | def delete_report(id: str, user: User = Depends(auth_with_jwt)): 36 | if has_permission(user): 37 | delete_report(id) 38 | return "OK" 39 | else: 40 | return PlainTextResponse("Permission denied", status_code=402) 41 | ``` 42 | -------------------------------------------------------------------------------- /documentation/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /web/src/features/todos/todo-list/TodoList.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Input } from '@equinor/eds-core-react' 2 | import { type ChangeEvent, type FormEventHandler, useEffect, useState } from 'react' 3 | import type { AddTodoResponse } from '../../../api/generated' 4 | import { useTodos } from '../../../contexts/TodoContext' 5 | import { useTodoAPI } from '../../../hooks/useTodoAPI' 6 | import TodoItem from './TodoItem' 7 | import { StyledInput, StyledTodoList } from './TodoList.styled' 8 | 9 | const AddItem = () => { 10 | const { addTodoItem } = useTodoAPI() 11 | const [value, setValue] = useState('') 12 | 13 | const add: FormEventHandler = (event) => { 14 | event.preventDefault() 15 | if (value) { 16 | addTodoItem(value) 17 | } 18 | setValue('') 19 | } 20 | 21 | return ( 22 |
23 |
24 | 25 | ) => setValue(e.target.value)} 30 | /> 31 | 34 | 35 |
36 |
37 | ) 38 | } 39 | 40 | const TodoList = () => { 41 | const { getAllTodoItems } = useTodoAPI() 42 | const { state, dispatch } = useTodos() 43 | 44 | useEffect(() => { 45 | getAllTodoItems() 46 | }, [dispatch, getAllTodoItems]) 47 | 48 | return ( 49 | 50 | 51 | {state.todoItems.map((todo: AddTodoResponse) => ( 52 | 53 | ))} 54 | 55 | ) 56 | } 57 | 58 | export default TodoList 59 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "vite", 7 | "build": "tsc && vite build", 8 | "serve": "vite preview", 9 | "compile": "tsc --noEmit", 10 | "generate": "openapi -i http://localhost:5000/openapi.json -o './src/api/generated' -c axios", 11 | "test": "vitest", 12 | "lint": "biome check --write --no-errors-on-unmatched" 13 | }, 14 | "browserslist": { 15 | "production": [">0.2%", "not dead", "not op_mini all"], 16 | "development": [ 17 | "last 1 chrome version", 18 | "last 1 firefox version", 19 | "last 1 safari version" 20 | ] 21 | }, 22 | "dependencies": { 23 | "@equinor/eds-core-react": "^0.48.0", 24 | "@equinor/eds-icons": "^0.22.0", 25 | "axios": "^1.11.0", 26 | "react": "^19.1.1", 27 | "react-dom": "^19.1.1", 28 | "react-oauth2-code-pkce": "^1.23.1", 29 | "react-router-dom": "^7.8.2", 30 | "styled-components": "^6.1.19" 31 | }, 32 | "devDependencies": { 33 | "@biomejs/biome": "^2.2.2", 34 | "@testing-library/dom": "^10.4.1", 35 | "@testing-library/jest-dom": "^6.8.0", 36 | "@testing-library/react": "^16.3.0", 37 | "@types/jest": "^30.0.0", 38 | "@types/node": "^24.3.0", 39 | "@types/react": "^19.1.12", 40 | "@types/react-dom": "^19.1.9", 41 | "@types/react-router": "^5.1.20", 42 | "@types/react-router-dom": "^5.3.3", 43 | "@vitejs/plugin-react": "^5.0.2", 44 | "jsdom": "^26.1.0", 45 | "openapi-typescript-codegen": "^0.29.0", 46 | "prettier": "^3.6.2", 47 | "typescript": "~5.9.2", 48 | "vite": "^7.1.3", 49 | "vite-plugin-checker": "^0.10.3", 50 | "vite-plugin-csp-guard": "^3.0.0", 51 | "vite-plugin-svgr": "^4.5.0", 52 | "vite-tsconfig-paths": "^5.1.4", 53 | "vitest": "^3.2.4" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /documentation/diagrams/fast-api.drawio: -------------------------------------------------------------------------------- 1 | 3Vpbk9smFP41nkkfsiMEuj1mb0ln2hlPN502j1QiMo0sXITXdn99wYK1JVjXyUpGuy+2OICA73zncA5iBm+W248crxa/soJUszAotjN4OwtDECWJ/FOSXSvJgrgVlJwWutFB8ED/JVoYaOmaFqTpNBSMVYKuusKc1TXJRUeGOWebbrOvrOqOusIlsQQPOa5s6R+0EItWmobJQf6J0HJhRgZx1tYssWmsV9IscME2RyJ4N4M3nDHRPi23N6RS4Blc2n73z9Q+TYyTWpzTQc/rEVdrvTY9L7Ezi+VsXRdEtQ9m8HqzoII8rHCuajdSvVK2EMtKloB8tMfXU3okXJDtkUjP5yNhSyL4TjbRtWGgsdHkiDJd3hyghka2OIY50kKs1Vs+vfuAgHzQILgBQdMDBERpBxCIIgsQFEcOQEzDlwASW+snhTQGXWRcLFjJalzdHaTXXYQObX5hbKVx+ZsIsdOWjdeCnYlaw9Y81/PQmhGYl0S3gq1IzfAkspxUWNDHrjm/BKXQos2nz5/nUvIb+WdNGnGCROAiJIKwa1Uhsq0KhA6rigcwqgj5IBHZUvGn6n4V6dKXo5rbrX7zvrAzhVou7aiTKn45rjt025dMv7MIC23Cpr4ICy3C/v5Ic8ZrRQnGvxE+OcrC9EzKpkNQ9gQ+73Czq/O9VvbG3ajxcV1UhP9koSYXK7rQNIKzb+SGVYxLSc1qxfavtKp6IlzRspbFXEIo1QGvFXRURiEfdMWSFsXeVFy66JrPENuQCWKMOiKHOlKHOsIB1AHTyW5DqW3VZtO8vFkj8JpwgpkvnFLLvB/kxCq5SqKGY2tB6/Lt2jKE2VXUsebIZc0BGMeaAbDgv8eN+DD/WQrbX1IXK0Zr5VvjSoH+l9yQ4lI9vZNpnYS9ku62ecP+FgLU0VAcODQUj+RvgRd/O2r0BRz5AvDmqM1sppsxgMxjxgCyye5jwBHGg8gbj+xAfmI8QonPzBN68WMvyzyTEZxfZJPWW+4JTiVXE0k++6y9bPIZ+WVt8kO0DUegbTwl2sbTp21/074obc17p7sX9b8tIOjKekaKqUM765kYPBAhj/BMPiJGqUf2oNcYyQy/I4SOHQGFvraE0N4SZolcSFBggeXfLLn1zton9plzhPBM1qIhWOvnHOFlrE0hPObt++AqQMn/kHdfmhNOJUTq8Og7GJ3ZjDaOxQOjXTcC7gUrWKPekVzLhhMgNUp6h2OuOGc0Uns5nHg2QQQ/6lfH9aG661yd4h55o6x77pz0A8/WNHSvnk6epnFeFuXlW8izCdEgajLmObaaUNb92Jck6VhqgnbWMN8VuBY0P+FmLnT1pg+Dw8047yINcRUJ2vnCbRtWSAmVEQZltYXQ2/n8gXrQX/IDFfJ8ADORYBs6QpPhPdDZ9uAITaYWbPePDS8bbCevkLUgDfvBdjBesG3CnGkE22Y2zmB7amnjiBG2LB6uPLchxOHiOLz7Dw== 2 | -------------------------------------------------------------------------------- /web/src/hooks/useTodoAPI.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import { type AddTodoResponse, ApiError, type ErrorResponse, TodoService } from '../api/generated' 3 | import { useTodos } from '../contexts/TodoContext' 4 | 5 | function handleError(error: unknown): void { 6 | if (error instanceof ApiError) { 7 | console.error((error.body as ErrorResponse).message) 8 | } 9 | throw error 10 | } 11 | 12 | export function useTodoAPI() { 13 | const { dispatch } = useTodos() 14 | 15 | const addTodoItem = useCallback(async (title: string) => { 16 | try { 17 | const todoItem = await TodoService.create({ title }) 18 | dispatch({ type: 'ADD_TODO', payload: todoItem }) 19 | return todoItem 20 | } catch (error) { 21 | handleError(error) 22 | } 23 | }, []) 24 | 25 | const getAllTodoItems = useCallback(async () => { 26 | try { 27 | const todoItems = await TodoService.getAll() 28 | dispatch({ type: 'INITIALIZE', payload: todoItems }) 29 | return todoItems 30 | } catch (error) { 31 | handleError(error) 32 | } 33 | }, []) 34 | 35 | const toggleTodoItem = useCallback(async (todoItem: AddTodoResponse) => { 36 | try { 37 | await TodoService.updateById(todoItem.id, { 38 | is_completed: !todoItem.is_completed, 39 | title: todoItem.title, 40 | }) 41 | dispatch({ type: 'TOGGLE_TODO', payload: todoItem }) 42 | } catch (error) { 43 | handleError(error) 44 | } 45 | }, []) 46 | 47 | const removeTodoItem = useCallback(async (todoItem: AddTodoResponse) => { 48 | try { 49 | await TodoService.deleteById(todoItem.id) 50 | dispatch({ type: 'REMOVE_TODO', payload: todoItem }) 51 | } catch (error) { 52 | handleError(error) 53 | } 54 | }, []) 55 | 56 | return { addTodoItem, getAllTodoItems, toggleTodoItem, removeTodoItem } 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/on-push-main-branch.yaml: -------------------------------------------------------------------------------- 1 | name: "Push to main branch" 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - "CHANGELOG.md" 9 | 10 | jobs: 11 | linting-and-checks: 12 | name: "Linting and checks" 13 | uses: ./.github/workflows/linting-and-checks.yaml 14 | 15 | tests: 16 | name: "Tests" 17 | uses: ./.github/workflows/tests.yaml 18 | 19 | generate-changelog: 20 | needs: tests 21 | name: "Generate changelog" 22 | uses: ./.github/workflows/generate-changelog.yaml 23 | 24 | docs: 25 | needs: generate-changelog 26 | name: "Build and publish docs" 27 | uses: ./.github/workflows/publish-docs.yaml 28 | with: 29 | message: "Warning: This is the development version." 30 | 31 | publish-latest: 32 | needs: tests 33 | name: "Publish dev docker images" 34 | uses: ./.github/workflows/publish-image.yaml 35 | with: 36 | image-tags: latest 37 | # uncomment to enable deployment of dev environment to radix 38 | # deploy-dev: 39 | # needs: publish-latest 40 | # uses: ./.github/workflows/deploy-to-radix.yaml 41 | # with: 42 | # image-tag: "latest" 43 | # radix-environment: "dev" 44 | 45 | release-please: 46 | needs: tests 47 | name: "Create or update release PR" 48 | uses: ./.github/workflows/create-release-pr.yaml 49 | 50 | publish-staging: 51 | needs: release-please 52 | if: ${{ needs.release-please.outputs.release_created }} 53 | name: "Publish staging docker images" 54 | uses: ./.github/workflows/publish-image.yaml 55 | with: 56 | image-tags: ${{ needs.release-please.outputs.tag_name }} 57 | # uncomment to enable deployment of staging environment to radix 58 | # deploy-staging: 59 | # needs: [release-please, publish-staging] 60 | # uses: ./.github/workflows/deploy-to-radix.yaml 61 | # with: 62 | # image-tag: ${{ needs.release-please.outputs.tag_name }} 63 | # radix-environment: "staging" 64 | -------------------------------------------------------------------------------- /api/src/authentication/authentication.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import jwt 3 | from cachetools import TTLCache, cached 4 | from fastapi import Security 5 | from fastapi.security import OAuth2AuthorizationCodeBearer 6 | 7 | from authentication.models import User 8 | from common.exceptions import UnauthorizedException 9 | from common.logger import logger 10 | from config import config, default_user 11 | 12 | oauth2_scheme = OAuth2AuthorizationCodeBearer( 13 | authorizationUrl=config.OAUTH_AUTH_ENDPOINT, 14 | tokenUrl=config.OAUTH_TOKEN_ENDPOINT, 15 | auto_error=False, 16 | ) 17 | 18 | 19 | @cached(cache=TTLCache(maxsize=32, ttl=86400)) 20 | def get_JWK_client() -> jwt.PyJWKClient: 21 | try: 22 | oid_conf_response = httpx.get(config.OAUTH_WELL_KNOWN) 23 | oid_conf_response.raise_for_status() 24 | oid_conf = oid_conf_response.json() 25 | return jwt.PyJWKClient(oid_conf["jwks_uri"]) 26 | except Exception as error: 27 | logger.error(f"Failed to fetch OpenId Connect configuration for '{config.OAUTH_WELL_KNOWN}': {error}") 28 | raise UnauthorizedException 29 | 30 | 31 | def auth_with_jwt(jwt_token: str = Security(oauth2_scheme)) -> User: 32 | if not config.AUTH_ENABLED: 33 | return default_user 34 | if not jwt_token: 35 | raise UnauthorizedException 36 | key = get_JWK_client().get_signing_key_from_jwt(jwt_token).key 37 | try: 38 | payload = jwt.decode(jwt_token, key, algorithms=["RS256"], audience=config.OAUTH_AUDIENCE) 39 | if config.MICROSOFT_AUTH_PROVIDER in payload["iss"]: 40 | # Azure AD uses an oid string to uniquely identify users. Each user has a unique oid value. 41 | user = User(user_id=payload["oid"], **payload) 42 | else: 43 | user = User(user_id=payload["sub"], **payload) 44 | except jwt.exceptions.InvalidTokenError as error: 45 | logger.warning(f"Failed to decode JWT: {error}") 46 | raise UnauthorizedException 47 | 48 | if user is None: 49 | raise UnauthorizedException 50 | return user 51 | -------------------------------------------------------------------------------- /web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.29.1-alpine AS server 2 | 3 | RUN apk upgrade --update-cache 4 | 5 | # Run as non-root 6 | RUN deluser nginx 7 | RUN adduser --disabled-password --no-create-home --gecos "" --uid 1000 nginx 8 | 9 | # Copy configs 10 | COPY nginx/nginx.conf /etc/nginx/nginx.conf 11 | COPY nginx/config/ /etc/nginx/config 12 | 13 | # Remove default nginx config 14 | RUN rm /etc/nginx/conf.d/default.conf 15 | 16 | # Copy sites-available into sites-enabled 17 | COPY nginx/sites-available/default.conf /etc/nginx/sites-enabled/default.conf 18 | 19 | # Create log directory if not present, set permissions 20 | RUN mkdir -p /var/log/nginx && \ 21 | chown -R nginx:nginx /var/log/nginx 22 | 23 | # Create tmp directory if not present, set permissions 24 | RUN mkdir -p /tmp/nginx && \ 25 | chown -R nginx:nginx /tmp/nginx 26 | 27 | # Create pidfile, set permissions 28 | RUN touch /var/run/nginx.pid && \ 29 | chown -R nginx:nginx /var/run/nginx.pid 30 | 31 | # Run master process as non-root user 32 | USER 1000 33 | 34 | FROM node:24 AS base 35 | ARG AUTH_ENABLED=0 36 | # Azure AD requires a scope. 37 | ARG AUTH_SCOPE="" 38 | ARG CLIENT_ID="" 39 | ARG TENANT_ID="" 40 | ENV VITE_AUTH_SCOPE=$AUTH_SCOPE 41 | ENV VITE_AUTH=$AUTH_ENABLED 42 | ENV VITE_AUTH_CLIENT_ID=$CLIENT_ID 43 | ENV VITE_AUTH_TENANT=$TENANT_ID 44 | ENV VITE_TOKEN_ENDPOINT=https://login.microsoftonline.com/${VITE_AUTH_TENANT}/oauth2/v2.0/token 45 | ENV VITE_AUTH_ENDPOINT=https://login.microsoftonline.com/${VITE_AUTH_TENANT}/oauth2/v2.0/authorize 46 | ENV VITE_LOGOUT_ENDPOINT=https://login.microsoftonline.com/${VITE_AUTH_TENANT}/oauth2/logout 47 | 48 | WORKDIR /code 49 | COPY ./ ./ 50 | RUN yarn install 51 | 52 | FROM base AS development 53 | CMD ["yarn", "start"] 54 | 55 | FROM server AS nginx-dev 56 | COPY nginx/environments/web.dev.conf /etc/nginx/environments/ 57 | 58 | FROM base AS build 59 | RUN yarn build 60 | 61 | FROM server AS nginx-prod 62 | COPY nginx/environments/web.prod.conf /etc/nginx/environments/ 63 | COPY --from=build /code/build /data/www 64 | -------------------------------------------------------------------------------- /api/src/authentication/access_control.py: -------------------------------------------------------------------------------- 1 | from authentication.models import ACL, AccessLevel, User 2 | from common.exceptions import MissingPrivilegeException 3 | from config import config 4 | 5 | 6 | def access_control(acl: ACL, access_level_required: AccessLevel, user: User) -> bool: 7 | """ 8 | This is the main access control function. 9 | It will either return True or an MissingPrivilegeException. 10 | Input is the ACL for the document to check, the AccessLevel required for the action, 11 | and the requests authenticated user. 12 | """ 13 | if not config.AUTH_ENABLED: 14 | return True 15 | 16 | if not user.scope.check_privilege(access_level_required): # The user object has a limited scope set in PAT. 17 | raise MissingPrivilegeException(f"The requested operation requires '{access_level_required.name}' privileges") 18 | 19 | # Starting with the 'others' access level should reduce the amount of checks being done the most 20 | if acl.others.check_privilege(access_level_required): 21 | return True 22 | # The owner always has full access 23 | if acl.owner == user.user_id: 24 | return True 25 | 26 | for role in user.roles: 27 | if role_access := acl.roles.get(role): 28 | if role_access.check_privilege(access_level_required): 29 | return True 30 | 31 | if direct_user_access := acl.users.get(user.user_id): 32 | if direct_user_access.check_privilege(access_level_required): 33 | return True 34 | 35 | # No access high enough granted neither as 'owner', 'roles', 'users', nor 'others'. 36 | raise MissingPrivilegeException(f"The requested operation requires '{access_level_required.name}' privileges") 37 | 38 | 39 | def create_acl(user: User) -> ACL: 40 | """Used when there is no ACL to inherit. Sets the current user as owner, and rest copies DEFAULT_ACL""" 41 | return ACL(owner=user.user_id, roles=DEFAULT_ACL.roles, others=DEFAULT_ACL.others) 42 | 43 | 44 | DEFAULT_ACL = ACL( 45 | owner=config.APPLICATION_ADMIN, 46 | roles={config.APPLICATION_ADMIN_ROLE: AccessLevel.WRITE}, 47 | others=AccessLevel.READ, 48 | ) 49 | -------------------------------------------------------------------------------- /docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | services: 2 | api: 3 | build: 4 | target: development 5 | image: template-api-dev 6 | volumes: 7 | - ./api/src/:/code/src 8 | env_file: 9 | - .env 10 | environment: 11 | ENVIRONMENT: local 12 | LOGGING_LEVEL: debug 13 | MONGODB_DATABASE: $MONGODB_DATABASE 14 | MONGODB_USERNAME: $MONGODB_USERNAME 15 | MONGODB_PASSWORD: $MONGODB_PASSWORD 16 | AUTH_ENABLED: $AUTH_ENABLED 17 | MONGODB_HOSTNAME: db 18 | MONGODB_PORT: $MONGODB_PORT 19 | OAUTH_TOKEN_ENDPOINT: $OAUTH_TOKEN_ENDPOINT 20 | OAUTH_AUTH_ENDPOINT: $OAUTH_AUTH_ENDPOINT 21 | OAUTH_WELL_KNOWN: $OAUTH_WELL_KNOWN 22 | OAUTH_AUDIENCE: $OAUTH_AUDIENCE 23 | OAUTH_AUTH_SCOPE: $AUTH_SCOPE 24 | OAUTH_CLIENT_ID: $CLIENT_ID 25 | SECRET_KEY: $SECRET_KEY 26 | ports: 27 | - "5000:5000" 28 | depends_on: 29 | - db 30 | links: 31 | - db 32 | 33 | nginx: 34 | build: 35 | target: nginx-dev 36 | 37 | web: 38 | restart: unless-stopped 39 | build: 40 | target: development 41 | context: ./web 42 | args: 43 | AUTH_ENABLED: $AUTH_ENABLED 44 | AUTH_SCOPE: $AUTH_SCOPE 45 | CLIENT_ID: $CLIENT_ID 46 | TENANT_ID: $TENANT_ID 47 | image: template-web-dev 48 | stdin_open: true 49 | volumes: 50 | - ./web/src:/code/src 51 | env_file: 52 | - .env 53 | environment: 54 | - NODE_ENV=development 55 | 56 | db: 57 | volumes: 58 | - database:/data/db 59 | env_file: 60 | - .env 61 | environment: 62 | MONGO_INITDB_ROOT_USERNAME: $MONGODB_USERNAME 63 | MONGO_INITDB_ROOT_PASSWORD: $MONGODB_PASSWORD 64 | 65 | volumes: 66 | database: 67 | 68 | # db-ui: 69 | # image: mongo-express:0.49 70 | # restart: unless-stopped 71 | # ports: 72 | # - "8081:8081" 73 | # env_file: 74 | # - .env 75 | # environment: 76 | # ME_CONFIG_MONGODB_SERVER: db 77 | # ME_CONFIG_MONGODB_ADMINUSERNAME: $MONGODB_USERNAME 78 | # ME_CONFIG_MONGODB_ADMINPASSWORD: $MONGODB_PASSWORD 79 | # ME_CONFIG_MONGODB_ENABLE_ADMIN: "true" 80 | -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yaml: -------------------------------------------------------------------------------- 1 | name: Generate documentation 2 | 3 | on: 4 | # Workflow dispatch is used for manual triggers 5 | workflow_dispatch: 6 | inputs: 7 | message: 8 | description: "A message to shown in the changelog" 9 | default: "" 10 | required: false 11 | type: string 12 | # Workflow call is used for called from another workflow 13 | workflow_call: 14 | inputs: 15 | message: 16 | description: "A message to shown in the changelog" 17 | default: "" 18 | required: false 19 | type: string 20 | 21 | env: 22 | GITHUB_PAGES_BRANCH: gh-pages 23 | 24 | jobs: 25 | publish-docs: 26 | runs-on: ubuntu-latest 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v5 31 | 32 | - name: Setup node 33 | uses: actions/setup-node@v5 34 | with: 35 | node-version: 20 36 | cache: yarn 37 | cache-dependency-path: documentation/yarn.lock 38 | 39 | - name: Download CHANGELOG 40 | uses: actions/download-artifact@v5 41 | with: 42 | name: CHANGELOG 43 | 44 | - name: "Add changelog" 45 | shell: bash 46 | run: | 47 | sed -i -e '1i${{ inputs.message }}\' CHANGELOG.md 48 | cp CHANGELOG.md documentation/src/pages/changelog.md 49 | 50 | - name: Install dependencies and build website 51 | run: | 52 | cd documentation 53 | yarn install --frozen-lockfile 54 | yarn build 55 | 56 | - name: Push static files to Github Pages branch 57 | run: | 58 | cd documentation/build 59 | CREATED_FROM_REF=$(git rev-parse --short HEAD) 60 | git init 61 | git config user.name "GitHub Actions Bot" 62 | git config user.email "<>" 63 | git checkout -b $GITHUB_PAGES_BRANCH 64 | git remote add $GITHUB_PAGES_BRANCH https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/equinor/template-fastapi-react 65 | git add . 66 | git commit -m "Built from commit '$CREATED_FROM_REF'" 67 | git push -f --set-upstream gh-pages gh-pages 68 | -------------------------------------------------------------------------------- /.github/workflows/generate-changelog.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | # Workflow dispatch is used for manual triggers 3 | workflow_dispatch: 4 | # Workflow call is used for called from another workflow 5 | workflow_call: 6 | 7 | env: 8 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 9 | 10 | jobs: 11 | generate-changelog: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/setup-python@v6 16 | with: 17 | python-version: "3.12" 18 | 19 | - uses: actions/setup-node@v5 20 | with: 21 | node-version: 20 22 | 23 | - name: Install dependencies 24 | run: | 25 | sudo apt-get update 26 | sudo apt-get install -y software-properties-common 27 | sudo add-apt-repository -y ppa:git-core/ppa 28 | sudo apt-get install -y git 29 | 30 | # Checkout repository. By setting `fetch-depth: 0`, this fetch will include all history and tags 31 | - name: Checkout 32 | uses: actions/checkout@v5 33 | with: 34 | fetch-depth: 0 35 | token: ${{ secrets.GITHUB_TOKEN }} 36 | 37 | # Configure git identity with the user set in last log entrance 38 | - name: Configure Git identity 39 | run: | 40 | git config --local user.email "$(git log --format='%ae' HEAD^!)" 41 | git config --local user.name "$(git log --format='%an' HEAD^!)" 42 | git config --global core.autocrlf true 43 | git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/equinor/boilerplate-clean-architecture 44 | 45 | - name: Install Precommit 46 | run: pip install pre-commit 47 | 48 | # Bump version with standard-version, remove prefixes from version tag 49 | - name: Bump version 50 | run: npx standard-version --tag-prefix= 51 | 52 | # Create changelog artifact 53 | - name: Upload changelog 54 | uses: actions/upload-artifact@v4 55 | with: 56 | name: CHANGELOG 57 | path: CHANGELOG.md 58 | 59 | # Set the new version number to an environment variable 60 | - name: Retrieve new version 61 | id: tag 62 | run: | 63 | echo "version=$(git describe HEAD)" >> $GITHUB_OUTPUT 64 | -------------------------------------------------------------------------------- /api/src/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | from pydantic_settings import BaseSettings 3 | 4 | from authentication.models import User 5 | from common.logger_level import LoggerLevel 6 | 7 | 8 | class Config(BaseSettings): 9 | # Pydantic-settings in pydantic v2 automatically fetch config settings from env-variables 10 | ENVIRONMENT: str = "local" 11 | 12 | # Logging 13 | LOGGER_LEVEL: LoggerLevel = Field(default=LoggerLevel.INFO) 14 | APPINSIGHTS_CONSTRING: str | None = None 15 | 16 | # Database 17 | MONGODB_USERNAME: str = "dummy" 18 | MONGODB_PASSWORD: str = "dummy" # noqa: S105 19 | MONGODB_HOSTNAME: str = "db" 20 | MONGODB_DATABASE: str = "test" 21 | MONGODB_PORT: int = 27017 22 | 23 | # Access control 24 | APPLICATION_ADMIN: str = "admin" 25 | APPLICATION_ADMIN_ROLE: str = "admin" 26 | 27 | # Authentication 28 | SECRET_KEY: str | None = None 29 | AUTH_ENABLED: bool = False 30 | JWT_SELF_SIGNING_ISSUER: str = "APPLICATION" # Which value will be used to sign self-signed JWT's 31 | TEST_TOKEN: bool = False # This value should only be changed at runtime by test setup 32 | OAUTH_WELL_KNOWN: str | None = None 33 | OAUTH_TOKEN_ENDPOINT: str = "" 34 | OAUTH_AUTH_ENDPOINT: str = "" 35 | OAUTH_CLIENT_ID: str = "" 36 | OAUTH_AUTH_SCOPE: str = "" 37 | OAUTH_AUDIENCE: str = "" 38 | MICROSOFT_AUTH_PROVIDER: str = "login.microsoftonline.com" 39 | 40 | @property 41 | def log_level(self) -> str: 42 | """Returns LOGGER_LEVEL as a (lower case) string.""" 43 | return str(self.LOGGER_LEVEL.value).lower() 44 | 45 | 46 | config = Config() 47 | 48 | if config.AUTH_ENABLED and not all((config.OAUTH_AUTH_ENDPOINT, config.OAUTH_TOKEN_ENDPOINT, config.OAUTH_WELL_KNOWN)): 49 | raise ValueError("Authentication was enabled, but some auth configuration parameters are missing") 50 | 51 | if not config.AUTH_ENABLED: 52 | print("\n") 53 | print("################ WARNING ################") 54 | print("# Authentication is disabled #") 55 | print("################ WARNING ################\n") 56 | 57 | default_user: User = User( 58 | user_id="nologin", 59 | full_name="Not Authenticated", 60 | email="nologin@example.com", 61 | ) 62 | -------------------------------------------------------------------------------- /api/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "api" 3 | version = "1.4.0" 4 | description = "API for Template Fastapi React" 5 | requires-python = ">=3.13" 6 | dependencies = [ 7 | "azure-monitor-opentelemetry>=1.6.5", 8 | "cachetools>=5.5.2", 9 | "certifi>=2025.4.26", 10 | "cryptography>=44.0.1", 11 | "fastapi[standard]>=0.115.8", 12 | "httpx>=0.28", 13 | "opentelemetry-instrumentation-fastapi>=0.51b0", 14 | "pydantic>=2.10", 15 | "pydantic-extra-types>=2.10", 16 | "pydantic-settings>=2.8", 17 | "pyjwt>=2.8.0", 18 | "pymongo>=4.11.1", 19 | ] 20 | 21 | [dependency-groups] 22 | dev = [ 23 | "mongomock>=4.1.2", 24 | "mypy>=1.14.1", 25 | "pre-commit>=3", 26 | "pytest>=8.3.0", 27 | "types-cachetools>=5.5.0.20240820", 28 | ] 29 | 30 | 31 | [tool.mypy] 32 | plugins = ["pydantic.mypy"] 33 | strict = true 34 | exclude = ["/tests/"] 35 | ignore_missing_imports = true 36 | namespace_packages = true 37 | explicit_package_bases = true 38 | disallow_subclassing_any = false 39 | 40 | [tool.ruff] 41 | src = ["src"] 42 | target-version = "py312" 43 | line-length = 119 44 | 45 | [tool.ruff.lint] 46 | select = [ 47 | "E", # pycodestyle errors 48 | "W", # pycodestyle warnings 49 | "F", # pyflakes 50 | "I", # isort 51 | "S", # flake8-bandit 52 | "C", # flake8-comprehensions 53 | "B", # flake8-bugbear 54 | "UP", # automatically upgrade syntax for newer versions of the language 55 | ] 56 | ignore = [ 57 | "B904", # TODO: Within an except clause, raise exceptions with `raise ... from err` or `raise ... from None` to distinguish them from errors in exception handling 58 | "B008", # do not perform function calls in argument defaults. Ignored to allow dependencies in FastAPI 59 | ] 60 | 61 | [tool.ruff.lint.per-file-ignores] 62 | "__init__.py" = [ 63 | "E402", 64 | ] # Ignore `E402` (import violations) in all `__init__.py` files 65 | "src/tests/*" = ["S101"] # Allow the use of ´assert´ in tests 66 | 67 | [tool.codespell] 68 | skip = "*.lock,*.cjs" 69 | ignore-words-list = "ignored-word" 70 | 71 | [tool.pytest.ini_options] 72 | # Makes pytest CLI discover markers and conftest settings: 73 | markers = [ 74 | "unit: mark a test as unit test.", 75 | "integration: mark a test as integration test.", 76 | ] 77 | testpaths = ["src/tests/unit", "src/tests/integration"] 78 | -------------------------------------------------------------------------------- /.github/workflows/linting-and-checks.yaml: -------------------------------------------------------------------------------- 1 | name: "Test" 2 | on: 3 | # Workflow dispatch is used for manual triggers 4 | workflow_dispatch: 5 | # Workflow call is used for called from another workflow 6 | workflow_call: 7 | secrets: 8 | CR_SECRET: 9 | description: "Secret to authenticate if using an other container registry than Github" 10 | required: false 11 | 12 | env: 13 | IMAGE_REGISTRY: ghcr.io 14 | REGISTRY_USER: $GITHUB_ACTOR 15 | API_IMAGE: ghcr.io/equinor/template-fastapi-react/api 16 | WEB_IMAGE: ghcr.io/equinor/template-fastapi-react/web 17 | 18 | jobs: 19 | pre-commit: 20 | runs-on: ubuntu-latest 21 | name: "pre-commit" 22 | steps: 23 | - name: "Setup: checkout repository" 24 | uses: actions/checkout@v5 25 | 26 | - name: "Setup: add python" 27 | uses: actions/setup-python@v6 28 | with: 29 | python-version: "3.12" 30 | 31 | - name: "Run: pre-commit" 32 | uses: pre-commit/action@v3.0.1 33 | with: 34 | extra_args: --all-files 35 | 36 | mypy: 37 | runs-on: ubuntu-latest 38 | name: "mypy static type checking" 39 | defaults: 40 | run: 41 | working-directory: ./api 42 | steps: 43 | - name: "Setup: checkout repository" 44 | uses: actions/checkout@v5 45 | 46 | - name: "Setup: install uv" 47 | uses: astral-sh/setup-uv@v6 48 | with: 49 | enable-cache: true 50 | 51 | - name: "Setup: install dependencies" 52 | run: uv sync --locked --dev 53 | 54 | - name: "Run: mypy" 55 | run: uv run mypy src 56 | 57 | typescript-compile: 58 | runs-on: ubuntu-latest 59 | name: "typescript compilation" 60 | defaults: 61 | run: 62 | working-directory: ./web 63 | steps: 64 | - name: "Setup: check out repository" 65 | uses: actions/checkout@v5 66 | with: 67 | sparse-checkout: | 68 | .github 69 | web 70 | 71 | - name: "Setup: add node" 72 | uses: actions/setup-node@v5 73 | with: 74 | node-version: 20 75 | cache: yarn 76 | cache-dependency-path: web/yarn.lock 77 | 78 | - name: "Setup: yarn install" 79 | run: yarn install 80 | 81 | - name: "Run: compile" 82 | run: yarn compile 83 | -------------------------------------------------------------------------------- /api/src/tests/unit/common/test_exception_handler_integration.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | 4 | import pytest 5 | 6 | from common.exception_handlers import ( 7 | fall_back_exception_handler, 8 | generic_exception_handler, 9 | ) 10 | from common.exceptions import ( 11 | BadRequestException, 12 | ExceptionSeverity, 13 | MissingPrivilegeException, 14 | ) 15 | 16 | 17 | def test_fall_back_exception_handler(caplog, mock_request): 18 | exception_response = fall_back_exception_handler(mock_request, exc=ZeroDivisionError()) 19 | exception_response_dict = json.loads(exception_response.body) 20 | exception_error_id = re.search(r"\w{8}-\w{4}-\w{4}-\w{4}-\w{12}", exception_response_dict["debug"]).group(0) 21 | assert exception_response_dict["status"] == 500 22 | assert exception_response_dict["type"] == "ZeroDivisionError" 23 | assert exception_error_id in caplog.text 24 | 25 | 26 | @pytest.mark.parametrize( 27 | "exception, expected_response", 28 | [ 29 | ( 30 | BadRequestException(), 31 | { 32 | "status": 400, 33 | "type": "BadRequestException", 34 | "message": "Invalid data for the operation", 35 | "debug": "Unable to complete the requested operation with the given input values.", 36 | "extra": None, 37 | }, 38 | ), 39 | ( 40 | MissingPrivilegeException(), 41 | { 42 | "status": 403, 43 | "type": "MissingPrivilegeException", 44 | "message": "You do not have the required permissions", 45 | "debug": "Action denied because of insufficient permissions", 46 | "extra": None, 47 | }, 48 | ), 49 | ], 50 | ) 51 | def test_generic_exception_handler(caplog, mock_request, exception, expected_response): 52 | exception_response = generic_exception_handler(mock_request, exc=exception) 53 | assert json.loads(exception_response.body) == expected_response 54 | if exception.severity == ExceptionSeverity.ERROR: 55 | assert "ERROR" in caplog.text 56 | if exception.severity == ExceptionSeverity.WARNING: 57 | assert "WARNING" in caplog.text 58 | if exception.severity == ExceptionSeverity.CRITICAL: 59 | assert "CRITICAL" in caplog.text 60 | -------------------------------------------------------------------------------- /IaC/exceptionEmailNotification.bicep: -------------------------------------------------------------------------------- 1 | /* Example of separate deployment that uses existing resource. 2 | E.g.: sending email notifications on exceptions in (existing) Application Insights. 3 | Needs to be deployed on resource group level: 4 | az deployment group create --resource-group template-fastapi-react-dev --template-file ./exceptionEmailNotifications.bicep --parameters environment=staging 5 | */ 6 | resource appInsight 'Microsoft.Insights/components@2020-02-02' existing = { 7 | name: '${resourceGroup().name}-logs' 8 | } 9 | 10 | resource sendEmailActionGroup 'Microsoft.Insights/actionGroups@2023-01-01' = { 11 | name: 'send-email-action-group' 12 | location: 'global' 13 | properties: { 14 | groupShortName: 'ErrorNotify' 15 | enabled: true 16 | emailReceivers: [ 17 | { 18 | name: 'Notify Chris by email_-EmailAction-' 19 | emailAddress: 'chcl@equinor.com' 20 | useCommonAlertSchema: false 21 | } 22 | { 23 | name: 'Notify Eirik by email_-EmailAction-' 24 | emailAddress: 'eaks@equinor.com' 25 | useCommonAlertSchema: false 26 | } 27 | ] 28 | } 29 | } 30 | 31 | 32 | resource metricAlerts 'Microsoft.Insights/metricAlerts@2018-03-01' = { 33 | name: 'Send email on error in template-fastapi-react' 34 | location: 'global' 35 | properties: { 36 | description: 'When an error is detected in template-fastapi-react, an email is dispatched' 37 | severity: 1 38 | enabled: true 39 | scopes: [ 40 | appInsight.id 41 | ] 42 | evaluationFrequency: 'PT1H' 43 | windowSize: 'PT1H' 44 | criteria: { 45 | allOf: [ 46 | { 47 | threshold: 0 48 | name: 'Metric1' 49 | metricNamespace: 'microsoft.insights/components' 50 | metricName: 'exceptions/count' 51 | operator: 'GreaterThan' 52 | timeAggregation: 'Count' 53 | skipMetricValidation: false 54 | criterionType: 'StaticThresholdCriterion' 55 | } 56 | ] 57 | 'odata.type': 'Microsoft.Azure.Monitor.SingleResourceMultipleMetricCriteria' 58 | } 59 | autoMitigate: false 60 | targetResourceType: 'microsoft.insights/components' 61 | targetResourceRegion: 'norwayeast' 62 | actions: [ 63 | { 64 | actionGroupId: sendEmailActionGroup.id 65 | } 66 | ] 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /documentation/docs/about/running/02-configure.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | This document goes through all the different configuration options available. 4 | 5 | :::info 6 | 7 | Remember to restart 8 | 9 | Any changes you make to this file will only come into effect when you restart the 10 | server. 11 | 12 | ::: 13 | 14 | ## .env 15 | 16 | First, let's look at the options available in the `.env` file. 17 | 18 | ### Web 19 | 20 | #### Authentication 21 | 22 | - `AUTH_ENABLED`: To enable or disable authentication 23 | - `CLIENT_ID`: Find the app's client ID under Azure Active Directory service (also called application ID). The client ID is used to tell Azure which resource a user is attempting to access. 24 | - `TENANT_ID`: Find tenant ID through the Azure portal under Azure Active Directory service. Select properties and under scroll down to the Tenant ID field. 25 | - `AUTH_SCOPE`: Find the scope the Azure portal under Azure Active Directory service and App registrations. The scope is located under the expose an API. 26 | 27 | ### API 28 | 29 | #### System 30 | 31 | - `ENVIRONMENT`: local for hot-reloading, or production for speed 32 | - `LOGGER_LEVEL`: DEBUG, ERROR, INFO, WARN 33 | 34 | #### Database 35 | 36 | - `MONGODB_USERNAME`: The username 37 | - `MONGODB_PASSWORD`: The password 38 | - `MONGODB_HOSTNAME`: The host where it's running 39 | - `MONGODB_DATABASE`: The database to connect to 40 | - `MONGODB_PORT`: The port that is used 41 | 42 | #### Authentication 43 | 44 | - `OAUTH_TOKEN_ENDPOINT`: The endpoint to obtain tokens. 45 | - `OAUTH_AUTH_ENDPOINT`: The authorization endpoint performs authentication of the end-user (this is done by redirecting the user agent to this endpoint). 46 | - `OAUTH_WELL_KNOWN`: The endpoints that lists endpoints and other configuration options relevant to the OpenID Connect implementation in the project. 47 | - `OAUTH_AUDIENCE`: If using azure ad, audience is the azure client id. 48 | - `SECRET_KEY`: The secret used for signing JWT. 49 | 50 | Used by the docs: 51 | 52 | - `OAUTH_CLIENT_ID`: Find the app's client ID under Azure Active Directory service (also called application ID). The client ID is used to tell Azure which resource a user is attempting to access. 53 | - `OAUTH_AUTH_SCOPE`: Find the scope the Azure portal under Azure Active Directory service and App registrations. The scope is located under the expose an API. 54 | -------------------------------------------------------------------------------- /api/src/features/todo/repository/todo_repository.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from common.exceptions import NotFoundException 4 | from config import config 5 | from data_providers.clients.client_interface import ClientInterface 6 | from data_providers.clients.mongodb.mongo_database_client import MongoDatabaseClient 7 | from features.todo.entities.todo_item import TodoItem 8 | from features.todo.repository.todo_repository_interface import TodoRepositoryInterface 9 | 10 | 11 | def to_dict(todo_item: TodoItem) -> dict[str, Any]: 12 | _dict: dict[str, Any] = todo_item.__dict__ 13 | _dict["_id"] = todo_item.id 14 | return _dict 15 | 16 | 17 | def get_todo_repository() -> "TodoRepository": 18 | mongo_database_client = MongoDatabaseClient(collection_name="todos", database_name=config.MONGODB_DATABASE) 19 | return TodoRepository(client=mongo_database_client) 20 | 21 | 22 | class TodoRepository(TodoRepositoryInterface): 23 | client: ClientInterface 24 | 25 | def __init__(self, client: ClientInterface): 26 | self.client = client 27 | 28 | def update(self, todo_item: TodoItem) -> TodoItem: 29 | updated_todo_item = self.client.update(todo_item.id, to_dict(todo_item)) 30 | return TodoItem.from_dict(updated_todo_item) 31 | 32 | def delete(self, todo_item_id: str) -> None: 33 | is_deleted = self.client.delete(todo_item_id) 34 | if not is_deleted: 35 | raise NotFoundException 36 | 37 | def delete_all(self) -> None: 38 | self.client.delete_collection() 39 | 40 | def get(self, todo_item_id: str) -> TodoItem: 41 | todo_item = self.client.get(todo_item_id) 42 | return TodoItem.from_dict(todo_item) 43 | 44 | def create(self, todo_item: TodoItem) -> TodoItem | None: 45 | inserted_todo_item = self.client.create(to_dict(todo_item)) 46 | return TodoItem.from_dict(inserted_todo_item) 47 | 48 | def get_all(self) -> list[TodoItem]: 49 | todo_items: list[TodoItem] = [] 50 | for item in self.client.list_collection(): 51 | todo_items.append(TodoItem.from_dict(item)) 52 | return todo_items 53 | 54 | def find_one(self, filter: dict[str, Any]) -> TodoItem | None: 55 | todo_item = self.client.find_one(filter) 56 | if todo_item: 57 | return TodoItem.from_dict(todo_item) 58 | return None 59 | -------------------------------------------------------------------------------- /documentation/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import clsx from "clsx"; 3 | import Link from "@docusaurus/Link"; 4 | import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; 5 | import Layout from "@theme/Layout"; 6 | import styles from "./index.module.css"; 7 | 8 | const features = [ 9 | { title: "Auto-generated changelogs from Git history" }, 10 | { title: "Auto-generated API documentations (using FastAPI)" }, 11 | { title: "Auto-generated API clients (using openapi generator)" }, 12 | { title: "Pre-commit hooks" }, 13 | { title: "CI/CD (using Github Actions)" }, 14 | { title: "Pydantic data validation" }, 15 | { title: "OAuth2" }, 16 | { title: "Standardized API error and response model" }, 17 | { title: "Run using Docker" }, 18 | { title: "Documentation solution (using Docusaurus)" }, 19 | ]; 20 | 21 | function HomepageHeader() { 22 | const { siteConfig } = useDocusaurusContext(); 23 | return ( 24 |
25 |
26 |

{siteConfig.title}

27 |

{siteConfig.tagline}

28 |
29 | 33 | Start reading the docs 34 | 35 |
36 |
37 |
38 | ); 39 | } 40 | 41 | function Feature({ title }) { 42 | return
{title}
; 43 | } 44 | 45 | export default function Home(): JSX.Element { 46 | const { siteConfig } = useDocusaurusContext(); 47 | return ( 48 | 52 | 53 |
54 | {features?.length > 0 && ( 55 | <> 56 |

Features include:

57 |
58 | {features.map((props, idx) => ( 59 | 60 | ))} 61 |
62 | 63 | )} 64 |
65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /web/src/contexts/TodoContext.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react' 2 | import { createContext, useContext, useReducer } from 'react' 3 | import type { AddTodoResponse } from '../api/generated' 4 | 5 | // Type alias to make it make more sense in the code 6 | type TodoItem = AddTodoResponse 7 | 8 | /** 9 | * Definitions of the types of actions an user can do, 10 | * that will trigger an update of state. 11 | */ 12 | type Action = 13 | | { type: 'ADD_TODO'; payload: TodoItem } 14 | | { type: 'INITIALIZE'; payload: TodoItem[] } 15 | | { type: 'REMOVE_TODO'; payload: TodoItem } 16 | | { type: 'TOGGLE_TODO'; payload: TodoItem } 17 | type Dispatch = (action: Action) => void 18 | type State = { todoItems: TodoItem[] } 19 | type TodoProviderProps = { children: React.ReactNode } 20 | 21 | const TodoContext = createContext<{ state: State; dispatch: Dispatch } | undefined>(undefined) 22 | 23 | function TodoProvider({ children }: TodoProviderProps) { 24 | const [state, dispatch] = useReducer(todoReducer, { todoItems: [] }) 25 | const value = { state, dispatch } 26 | 27 | return {children} 28 | } 29 | 30 | function todoReducer(state: State, action: Action): State { 31 | switch (action.type) { 32 | case 'ADD_TODO': { 33 | return { 34 | ...state, 35 | todoItems: [...state.todoItems, action.payload], 36 | } 37 | } 38 | case 'INITIALIZE': { 39 | return { 40 | ...state, 41 | todoItems: action.payload, 42 | } 43 | } 44 | case 'REMOVE_TODO': { 45 | return { 46 | ...state, 47 | todoItems: state.todoItems.filter((todo) => todo.id !== action.payload.id), 48 | } 49 | } 50 | case 'TOGGLE_TODO': { 51 | return { 52 | ...state, 53 | todoItems: state.todoItems.map((todo) => 54 | todo !== action.payload ? todo : { ...todo, is_completed: !todo.is_completed } 55 | ), 56 | } 57 | } 58 | default: { 59 | throw new Error(`Unhandled action type: ${action}`) 60 | } 61 | } 62 | } 63 | 64 | // Custom hook to get the provided context value from TodoProvider 65 | function useTodos() { 66 | const context = useContext(TodoContext) 67 | if (context === undefined) { 68 | throw new Error('useTodos must be used within a TodoProvider') 69 | } 70 | return context 71 | } 72 | 73 | export { TodoProvider, useTodos } 74 | -------------------------------------------------------------------------------- /documentation/docs/contribute/development-guide/coding/extending-the-api/adding-features/01-controllers.md: -------------------------------------------------------------------------------- 1 | # Controller 2 | 3 | A controller receives a request, then calls a use case, before finally returning a response. 4 | 5 | The controller (adapter layer) is responsible for validating and transforming requests into an understandable format for the use cases (application layer). The format is defined inside the use cases by the request and response models. The controller takes the user input (the request), converts it into the request model defined by the use case and passes the request model to the use case, and at the end return the response model. 6 | 7 | ```mdx-code-block 8 | import CodeBlock from '@theme/CodeBlock'; 9 | 10 | import Controller from '!!raw-loader!@site/../api/src/features/todo/todo_feature.py'; 11 | 12 | {Controller} 13 | ``` 14 | 15 | * `Required` 16 | * The controller needs to be decorated with the `create_response` decorator, which handles exceptions and returns a unified response type. 17 | * The controller needs to have set the `response_model` and `request_model`, that is used to generate API documentation and used for validation. 18 | * `Optional` 19 | * Add [repository interface](../adding-data-providers/02-repository-interfaces.md) to handle communication to external services such as databases and inject the repository implementations to the controller endpoint and pass the injected repository implementations to the use case. 20 | 21 | :::note 22 | 23 | FastAPI is built around the [OpenAPI Specification](https://github.com/OAI/OpenAPI-Specification) (formerly known as swagger) standards. In FastAPI, by coding your endpoints, you are automatically writing your API documentation. FastAPI maps your endpoint details to a [JSON Schema](https://json-schema.org/) document. Under the hood, FastAPI uses Pydantic for data validation. With Pydantic along with [type hints](https://docs.python.org/3/library/typing.html), you get a nice editor experience with autocompletion. 24 | 25 | ::: 26 | 27 | ## Testing controllers 28 | 29 | Use the `test_client` fixture to populate the database with test data and `test_app` fixture to perform REST API calls. 30 | 31 | ```mdx-code-block 32 | import Test from '!!raw-loader!@site/../api/src/tests/integration/features/todo/test_todo_feature.py'; 33 | 34 | {Test} 35 | ``` 36 | 37 | :::note 38 | 39 | Mark it as integration test. 40 | 41 | ::: 42 | -------------------------------------------------------------------------------- /api/src/authentication/models.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | from typing import Any 3 | 4 | from pydantic import BaseModel, GetJsonSchemaHandler 5 | from pydantic_core import core_schema 6 | 7 | 8 | class AccessLevel(IntEnum): 9 | WRITE = 2 10 | READ = 1 11 | NONE = 0 12 | 13 | def check_privilege(self, required_level: "AccessLevel") -> bool: 14 | if self.value >= required_level.value: 15 | return True 16 | return False 17 | 18 | @classmethod 19 | def __get_pydantic_json_schema__( 20 | cls, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler 21 | ) -> dict[str, Any]: 22 | """ 23 | Add a custom field type to the class representing the Enum's field names 24 | Ref: https://pydantic-docs.helpmanual.io/usage/schema/#modifying-schema-in-custom-fields 25 | 26 | The specific key 'x-enum-varnames' is interpreted by the openapi-generator-cli 27 | to provide names for the Enum values. 28 | Ref: https://openapi-generator.tech/docs/templating/#enum 29 | """ 30 | json_schema = handler(core_schema) 31 | json_schema["x-enum-varnames"] = [choice.name for choice in cls] 32 | return json_schema 33 | 34 | 35 | class User(BaseModel): 36 | user_id: str # If using azure AD authentication, user_id is the oid field from the access token. 37 | # If using another oauth provider, user_id will be from the "sub" attribute in the access token. 38 | email: str | None = None 39 | full_name: str | None = None 40 | roles: list[str] = [] 41 | scope: AccessLevel = AccessLevel.WRITE 42 | 43 | def __hash__(self) -> int: 44 | return hash(type(self.user_id)) 45 | 46 | 47 | class ACL(BaseModel): 48 | """ 49 | acl: 50 | owner: 'user_id' 51 | roles: 52 | 'role': WRITE 53 | users: 54 | 'user_id': WRITE 55 | others: READ 56 | """ 57 | 58 | owner: str 59 | roles: dict[str, AccessLevel] = {} 60 | users: dict[str, AccessLevel] = {} 61 | others: AccessLevel = AccessLevel.READ 62 | 63 | def dict(self, **kwargs: Any) -> dict[str, str | dict[str, AccessLevel | str]]: 64 | return { 65 | "owner": self.owner, 66 | "roles": {k: v.name for k, v in self.roles.items()}, 67 | "users": {k: v.name for k, v in self.users.items()}, 68 | "others": self.others.name, 69 | } 70 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_stages: [pre-commit] 2 | default_install_hook_types: [pre-commit, commit-msg] 3 | repos: 4 | - repo: local 5 | hooks: 6 | - id: generate-api-client 7 | name: "Build: API TypeScript types" 8 | entry: ./web/generate-api-typescript-client-pre-commit.sh 9 | language: system 10 | pass_filenames: false 11 | 12 | - repo: https://github.com/compilerla/conventional-pre-commit 13 | rev: v3.4.0 14 | hooks: 15 | - id: conventional-pre-commit 16 | name: "Check: conventional formatted commit message" 17 | stages: [commit-msg] 18 | 19 | - repo: https://github.com/pre-commit/pre-commit-hooks 20 | rev: v5.0.0 21 | hooks: 22 | - id: check-merge-conflict 23 | name: "Check: no merge conflict strings" 24 | - id: no-commit-to-branch 25 | name: "Check: no commit to main" 26 | args: [--branch, main, --branch, master] 27 | stages: [commit-msg] 28 | - id: check-ast 29 | name: "Check: parse .py files" 30 | language_version: python3.12 31 | - id: check-json 32 | name: "Check: parse .json files" 33 | - id: check-toml 34 | name: "Check: parse .toml files" 35 | - id: check-yaml 36 | name: "Check: parse .yaml files" 37 | - id: check-case-conflict 38 | name: "Check: no case conflicting file names" 39 | - id: trailing-whitespace 40 | name: "Lint : remove trailing whitespaces" 41 | exclude: ^web/src/api/generated/|^.*\.(lock)$ 42 | - id: end-of-file-fixer 43 | name: "Lint : files end with only newline" 44 | exclude: ^web/src/api/generated/|^.*\.(lock)$ 45 | - id: mixed-line-ending 46 | name: "Lint : consistent file ending" 47 | exclude: ^.*\.(lock)$ 48 | - id: detect-private-key 49 | name: "Check: no private keys are commited" 50 | exclude: api/src/tests/integration/mock_authentication.py 51 | 52 | - repo: https://github.com/astral-sh/ruff-pre-commit 53 | rev: "v0.9.7" 54 | hooks: 55 | - id: ruff 56 | name: "Lint : ruff (python)" 57 | files: ^api/.*\.py$ 58 | args: ["--fix"] 59 | 60 | - id: ruff-format 61 | name: "Lint : ruff-format (python)" 62 | files: ^api/.*\.py$ 63 | 64 | - repo: https://github.com/biomejs/pre-commit 65 | rev: v0.6.1 66 | hooks: 67 | - id: biome-check 68 | name: "Lint : biome (ts/js)" 69 | additional_dependencies: ["@biomejs/biome@1.9.4"] 70 | args: ["--config-path", "web"] 71 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yaml: -------------------------------------------------------------------------------- 1 | # CodeQL analyzes our code and uploads the results to github. The result can be 2 | # found on https://github.com/equinor//security/code-scanning 3 | name: "Run CodeQL security analysis" 4 | 5 | on: 6 | push: 7 | branches: ["main"] 8 | pull_request: 9 | branches: ["main"] 10 | # uncomment this to run codeql weekly at 23:59 at Sundays 11 | # schedule: 12 | # - cron: "59 23 * * 0" 13 | 14 | jobs: 15 | analyze: 16 | name: Analyze 17 | runs-on: ubuntu-latest 18 | permissions: 19 | # required for all workflows 20 | security-events: write 21 | 22 | # required to fetch internal or private CodeQL packs 23 | packages: read 24 | 25 | # only required for workflows in private repositories 26 | actions: read 27 | contents: read 28 | 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | include: 33 | - language: javascript-typescript 34 | build-mode: none 35 | - language: python 36 | build-mode: none 37 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, 38 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. 39 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how 40 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages 41 | 42 | steps: 43 | - name: Checkout repository 44 | uses: actions/checkout@v5 45 | 46 | # Initializes the CodeQL tools for scanning. 47 | - name: Initialize CodeQL 48 | uses: github/codeql-action/init@v3 49 | with: 50 | languages: ${{ matrix.language }} 51 | build-mode: ${{ matrix.build-mode }} 52 | 53 | # If the analyze step fails for one of the languages you are analyzing with 54 | # "We were unable to automatically build your code", modify the matrix above 55 | # to set the build mode to "manual" for that language. 56 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages 57 | 58 | - name: Perform CodeQL Analysis 59 | uses: github/codeql-action/analyze@v3 60 | with: 61 | category: "/language:${{matrix.language}}" 62 | -------------------------------------------------------------------------------- /.github/workflows/deploy-to-radix.yaml: -------------------------------------------------------------------------------- 1 | name: "Deploy image to radix" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | radix-environment: 6 | description: "Which radix environment to deploy into" 7 | default: "test" 8 | required: true 9 | type: string 10 | image-tag: 11 | description: "Which image tag to deploy." 12 | required: true 13 | type: string 14 | workflow_call: # Workflow is meant to be called from another workflow 15 | inputs: 16 | radix-environment: 17 | description: "Which radix environment to deploy into" 18 | default: "test" 19 | required: true 20 | type: string 21 | image-tag: 22 | description: "Which image tag to deploy." 23 | required: true 24 | type: string 25 | 26 | permissions: 27 | id-token: write 28 | contents: read 29 | 30 | env: 31 | RADIX_APP: heracles 32 | RADIX_USER: heracles@equinor.com 33 | 34 | jobs: 35 | deploy-on-radix: 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v5 39 | 40 | # You'll need an app registration with a Federated Credential for this to 41 | # work. Note that the credential will need to specify a branch name. This 42 | # step will therefore fail for all branches not mentioned in the credentials 43 | - name: Az CLI login 44 | uses: azure/login@v2 45 | with: 46 | client-id: 4a761bec-628d-4c4b-860a-4903cbecc963 #app registration Application ID 47 | tenant-id: 3aa4a235-b6e2-48d5-9195-7fcf05b459b0 48 | allow-no-subscriptions: true 49 | 50 | - name: Get Azure principal token for Radix 51 | # The resource 6dae42f8-4368-4678-94ff-3960e28e3630 is a fixed Application ID, 52 | # corresponding to the Azure Kubernetes Service AAD Server. 53 | run: | 54 | token=$(az account get-access-token --resource 6dae42f8-4368-4678-94ff-3960e28e3630 --query=accessToken -otsv) 55 | echo "::add-mask::$token" 56 | echo "APP_SERVICE_ACCOUNT_TOKEN=$token" >> $GITHUB_ENV 57 | 58 | - name: Deploy on Radix 59 | uses: equinor/radix-github-actions@v1 60 | env: 61 | APP_SERVICE_ACCOUNT_TOKEN: ${{ env.APP_SERVICE_ACCOUNT_TOKEN }} 62 | with: 63 | args: > 64 | create job 65 | deploy 66 | --application $RADIX_APP 67 | --environment ${{ inputs.radix-environment }} 68 | --user $RADIX_USER 69 | --context playground 70 | --from-config 71 | --token-environment 72 | --follow 73 | --image-tag-name api=${{ inputs.image-tag }} 74 | --image-tag-name proxy=${{ inputs.image-tag }} 75 | -------------------------------------------------------------------------------- /documentation/docs/contribute/development-guide/coding/extending-the-web/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | # Extending the web 6 | 7 | The web is grouped by features. 8 | 9 | ## Codebase structure 10 | 11 | The web has a feature-based folder structure. 12 | 13 | ``` 14 | ├── web/ 15 | │ └── src/ 16 | │ ├── api/ 17 | │ ├── common/ 18 | │ ├── features/ 19 | │ │ ├── todos/ 20 | │ │ └── ... 21 | │ ├── hooks/ 22 | │ └── pages/ 23 | └── ... 24 | ``` 25 | 26 | - `api` contains the auto-generated API client 27 | - `common` contains shared code like generic components 28 | - `features` contains features e.g. todo-list 29 | - `hooks` contains reusable hooks 30 | - `pages` contains entrypoints (pages that are used by the router) 31 | 32 | ## Application tree 33 | 34 | ```mermaid 35 | 36 | flowchart 37 | subgraph web 38 | AuthProvider-. imported from .->react-oauth2-code-pkce; 39 | index --> AuthProvider 40 | AuthProvider --> app 41 | app --> Header 42 | app --> RouterProvider 43 | RouterProvider-. imported from .->react-router-dom; 44 | RouterProvider --> routes 45 | routes -- /invalid --> InvalidUrl 46 | routes -- / --> TodoListPage 47 | TodoListPage --> TodoList 48 | TodoList -- uses hook --> useTodos 49 | useTodos -- uses client --> TodoAPI 50 | TodoList --> AddItem 51 | TodoList -- 1..x --> TodoItem 52 | end 53 | 54 | TodoAPI -- HTTP requests --> API 55 | 56 | style react-oauth2-code-pkce fill:#f96; 57 | click react-oauth2-code-pkce "https://www.npmjs.com/package/react-oauth2-code-pkce" "Open" 58 | style react-router-dom fill:#f96; 59 | click react-router-dom "https://www.npmjs.com/package/react-router-dom" "Open" 60 | 61 | style useTodos fill:#b8c; 62 | ``` 63 | 64 | External dependencies: 65 | 66 | - The app is using [react-oauth2-code-pkce](https://www.npmjs.com/package/react-oauth2-code-pkce) for Oauth2 authentication. 67 | - The app is using [react-router-dom](https://www.npmjs.com/package/react-router-dom) for routing. 68 | 69 | ## Configuration 70 | 71 | See [configuration](../../../../about/running/configure) for a description of the different configuration options available. 72 | 73 | ### Oauth2 74 | 75 | The AuthProvider are using the configuration defined in `web/src/auth`. 76 | 77 | ```mdx-code-block 78 | import CodeBlock from '@theme/CodeBlock'; 79 | import auth from '!!raw-loader!@site/../web/src/auth'; 80 | 81 | {auth} 82 | ``` 83 | 84 | ### Routes 85 | 86 | The RouterProvider are using the router defined in `web/src/router`. 87 | 88 | ```mdx-code-block 89 | import Router from '!!raw-loader!@site/../web/src/router'; 90 | 91 | {Router} 92 | ``` 93 | -------------------------------------------------------------------------------- /api/src/features/todo/todo_feature.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | 3 | from authentication.authentication import auth_with_jwt 4 | from authentication.models import User 5 | from common.exception_handlers import ExceptionHandlingRoute 6 | from features.todo.repository.todo_repository import get_todo_repository 7 | from features.todo.repository.todo_repository_interface import TodoRepositoryInterface 8 | from features.todo.use_cases.add_todo import ( 9 | AddTodoRequest, 10 | AddTodoResponse, 11 | add_todo_use_case, 12 | ) 13 | from features.todo.use_cases.delete_todo_by_id import ( 14 | DeleteTodoByIdResponse, 15 | delete_todo_use_case, 16 | ) 17 | from features.todo.use_cases.get_todo_all import GetTodoAllResponse, get_todo_all_use_case 18 | from features.todo.use_cases.get_todo_by_id import ( 19 | GetTodoByIdResponse, 20 | get_todo_by_id_use_case, 21 | ) 22 | from features.todo.use_cases.update_todo import ( 23 | UpdateTodoRequest, 24 | UpdateTodoResponse, 25 | update_todo_use_case, 26 | ) 27 | 28 | router = APIRouter(tags=["todo"], prefix="/todos", route_class=ExceptionHandlingRoute) 29 | 30 | 31 | @router.post("", operation_id="create") 32 | def add_todo( 33 | data: AddTodoRequest, 34 | user: User = Depends(auth_with_jwt), 35 | todo_repository: TodoRepositoryInterface = Depends(get_todo_repository), 36 | ) -> AddTodoResponse: 37 | return add_todo_use_case(data=data, user_id=user.user_id, todo_repository=todo_repository) 38 | 39 | 40 | @router.get("/{id}", operation_id="get_by_id") 41 | def get_todo_by_id( 42 | id: str, 43 | user: User = Depends(auth_with_jwt), 44 | todo_repository: TodoRepositoryInterface = Depends(get_todo_repository), 45 | ) -> GetTodoByIdResponse: 46 | return get_todo_by_id_use_case(id=id, user_id=user.user_id, todo_repository=todo_repository) 47 | 48 | 49 | @router.delete("/{id}", operation_id="delete_by_id") 50 | def delete_todo_by_id( 51 | id: str, 52 | user: User = Depends(auth_with_jwt), 53 | todo_repository: TodoRepositoryInterface = Depends(get_todo_repository), 54 | ) -> DeleteTodoByIdResponse: 55 | return delete_todo_use_case(id=id, user_id=user.user_id, todo_repository=todo_repository) 56 | 57 | 58 | @router.get("", operation_id="get_all") 59 | def get_todo_all( 60 | user: User = Depends(auth_with_jwt), todo_repository: TodoRepositoryInterface = Depends(get_todo_repository) 61 | ) -> list[GetTodoAllResponse]: 62 | return get_todo_all_use_case(user_id=user.user_id, todo_repository=todo_repository) # type: ignore 63 | 64 | 65 | @router.put("/{id}", operation_id="update_by_id") 66 | def update_todo( 67 | id: str, 68 | data: UpdateTodoRequest, 69 | user: User = Depends(auth_with_jwt), 70 | todo_repository: TodoRepositoryInterface = Depends(get_todo_repository), 71 | ) -> UpdateTodoResponse: 72 | return update_todo_use_case(id=id, data=data, user_id=user.user_id, todo_repository=todo_repository) 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Template Fastapi React 4 | 5 | [![License][license-badge]][license] 6 | [![On push main branch][on-push-main-branch-badge]][on-push-main-branch-action] 7 | 8 | This is a **solution template** for creating a Single Page App (SPA) with React and FastAPI following the principles of Clean Architecture. 9 | 10 | [Key Features](#key-features) • [Quickstart](#quickstart) • [Development](#development) • [Contributing](#contributing) 11 | 12 | 15 | 16 |
17 | 18 | 19 | 20 | ## :dart: Key features 21 | 22 | - Clean architecture 23 | - Screaming architecture 24 | - Auto-generated changelogs 25 | - Auto-generated OpenAPI specification 26 | - Automatic documentation of REST API 27 | - Auto-generated REST API clients 28 | - Pre-commit hooks 29 | - Pydantic data validation 30 | 31 | 32 | ## :zap: Quickstart 33 | 34 | ### Prerequisites 35 | 36 | To run the template todo-list application ([Running](#running)): 37 | - [Docker](https://www.docker.com/) 38 | 39 | For development ([Development](#development)): 40 | - [Docker](https://www.docker.com/) 41 | - [Python 3.12](https://www.python.org/downloads/) or newer 42 | 43 | ### Configuration 44 | 45 | Environment variables is used for configuration and must be set before running. 46 | 47 | Create a copy of `.env-template` called `.env` and populate it with values: 48 | 49 | - `XYZ`: Specifies the [RESOURCE NAME] connection string 50 | 51 | **Note:** The template doesn't have any values that you need to replace, but any instantiated project probably will. 52 | 53 | ### Running 54 | 55 | Once you have done the configuration, you can start running: 56 | 57 | ```sh 58 | docker compose up --build 59 | ``` 60 | 61 | The application will be served at http://localhost 62 | 63 | The API documentation can be found at http://localhost:5000/docs 64 | 65 | 66 | ## :dizzy: Development 67 | 68 | See the [docs](https://equinor.github.io/template-fastapi-react/) if you want to start developing. 69 | 70 | 71 | ## :+1: Contributing 72 | 73 | Thanks for your interest in contributing! There are many ways to contribute to this project. Get started [here](CONTRIBUTING.md). 74 | 75 | [license-badge]: https://img.shields.io/badge/License-MIT-yellow.svg 76 | [license]: https://github.com/equinor/boilerplate-clean-architecture/blob/main/LICENSE 77 | [releases]: https://github.com/equinor/boilerplate-clean-architecture/releases 78 | [on-push-main-branch-badge]: https://github.com/equinor/boilerplate-clean-architecture/actions/workflows/on-push-main-branch.yaml/badge.svg 79 | [on-push-main-branch-action]: https://github.com/equinor/boilerplate-clean-architecture/actions/workflows/on-push-main-branch.yaml 80 | -------------------------------------------------------------------------------- /web/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx nginx; 2 | pid /var/run/nginx.pid; 3 | worker_processes auto; 4 | worker_rlimit_nofile 65535; 5 | 6 | include /etc/nginx/modules-enabled/*.conf; 7 | 8 | events { 9 | multi_accept on; 10 | worker_connections 65535; 11 | } 12 | 13 | http { 14 | charset utf-8; 15 | sendfile on; 16 | tcp_nopush on; 17 | tcp_nodelay on; 18 | server_tokens off; 19 | log_not_found off; 20 | types_hash_max_size 2048; 21 | types_hash_bucket_size 64; 22 | client_max_body_size 32M; 23 | 24 | # logging 25 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 26 | '$status $body_bytes_sent "$http_referrer" ' 27 | '"$http_user_agent" "$http_x_forwarded_for"'; 28 | 29 | # MIME 30 | include mime.types; 31 | default_type application/octet-stream; 32 | 33 | # Logging 34 | access_log /var/log/nginx/access.log main; 35 | error_log /var/log/nginx/error.log warn; 36 | 37 | # temp paths 38 | proxy_temp_path /tmp/nginx/proxy_temp; 39 | client_body_temp_path /tmp/nginx/client_temp; 40 | fastcgi_temp_path /tmp/nginx/fastcgi_temp; 41 | uwsgi_temp_path /tmp/nginx/uwsgi_temp; 42 | scgi_temp_path /tmp/nginx/scgi_temp; 43 | 44 | map $remote_addr $proxy_forwarded_elem { 45 | # IPv4 addresses can be sent as-is 46 | ~^[0-9.]+$ "for=$remote_addr"; 47 | # IPv6 addresses need to be bracketed and quoted 48 | ~^[0-9A-Fa-f:.]+$ "for=\"[$remote_addr]\""; 49 | # Unix domain socket names cannot be represented in RFC 7239 syntax 50 | default "for=unknown"; 51 | } 52 | 53 | map $http_forwarded $proxy_add_forwarded { 54 | # If the incoming Forwarded header is syntactically valid, append to it 55 | "~^(,[ \\t]*)*([!#$%&'*+.^_`|~0-9A-Za-z-]+=([!#$%&'*+.^_`|~0-9A-Za-z-]+|\"([\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*\"))?(;([!#$%&'*+.^_`|~0-9A-Za-z-]+=([!#$%&'*+.^_`|~0-9A-Za-z-]+|\"([\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*\"))?)*([ \\t]*,([ \\t]*([!#$%&'*+.^_`|~0-9A-Za-z-]+=([!#$%&'*+.^_`|~0-9A-Za-z-]+|\"([\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*\"))?(;([!#$%&'*+.^_`|~0-9A-Za-z-]+=([!#$%&'*+.^_`|~0-9A-Za-z-]+|\"([\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*\"))?)*)?)*$" "$http_forwarded, $proxy_forwarded_elem"; 56 | # Otherwise, replace it 57 | default "$proxy_forwarded_elem"; 58 | } 59 | 60 | include /etc/nginx/conf.d/*.conf; 61 | include /etc/nginx/sites-enabled/*; 62 | } 63 | -------------------------------------------------------------------------------- /api/src/app.py: -------------------------------------------------------------------------------- 1 | import click 2 | from fastapi import APIRouter, FastAPI, Security 3 | from starlette.middleware import Middleware 4 | 5 | from authentication.authentication import auth_with_jwt 6 | from common.middleware import LocalLoggerMiddleware 7 | from common.responses import responses 8 | from config import config 9 | from features.health_check import health_check_feature 10 | from features.todo import todo_feature 11 | from features.whoami import whoami_feature 12 | 13 | description_md = """ 14 | ### Description 15 | A RESTful API for handling todo items. 16 | 17 | Anyone in Equinor are authorized to use the API. 18 | * Click **Authorize** to login and start testing. 19 | 20 | ### Resources 21 | * [Docs](https://equinor.github.io/template-fastapi-react/) 22 | * [Github](https://github.com/equinor/template-fastapi-react) 23 | 24 | For questions about usage or expanding the API, create issue on Github or see docs. 25 | """ 26 | 27 | 28 | def create_app() -> FastAPI: 29 | public_routes = APIRouter() 30 | public_routes.include_router(health_check_feature.router) 31 | 32 | authenticated_routes = APIRouter() 33 | authenticated_routes.include_router(todo_feature.router) 34 | authenticated_routes.include_router(whoami_feature.router) 35 | 36 | middleware = [Middleware(LocalLoggerMiddleware)] 37 | 38 | app = FastAPI( 39 | title="Template FastAPI React", 40 | version="1.4.0", # x-release-please-version 41 | description=description_md, 42 | responses=responses, 43 | middleware=middleware, 44 | license_info={"name": "MIT", "url": "https://github.com/equinor/template-fastapi-react/blob/main/LICENSE.md"}, 45 | swagger_ui_init_oauth={ 46 | "clientId": config.OAUTH_CLIENT_ID, 47 | "appName": "TemplateFastAPIReact", 48 | "usePkceWithAuthorizationCodeGrant": True, 49 | "scopes": config.OAUTH_AUTH_SCOPE, 50 | "useBasicAuthenticationWithAccessCodeGrant": True, 51 | }, 52 | ) 53 | 54 | if config.APPINSIGHTS_CONSTRING: 55 | from azure.monitor.opentelemetry import configure_azure_monitor 56 | from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor 57 | 58 | configure_azure_monitor(connection_string=config.APPINSIGHTS_CONSTRING, logger_name="API") 59 | FastAPIInstrumentor.instrument_app(app, excluded_urls="healthcheck") 60 | 61 | app.include_router(authenticated_routes, dependencies=[Security(auth_with_jwt)]) 62 | app.include_router(public_routes) 63 | 64 | return app 65 | 66 | 67 | @click.group() 68 | def cli() -> None: 69 | pass 70 | 71 | 72 | @cli.command() 73 | def run() -> None: 74 | import uvicorn 75 | 76 | uvicorn.run( 77 | "app:create_app", 78 | host="0.0.0.0", # noqa:S104 79 | port=5000, 80 | factory=True, 81 | reload=config.ENVIRONMENT == "local", 82 | log_level=config.log_level, 83 | ) 84 | 85 | 86 | if __name__ == "__main__": 87 | cli() # run commands in cli() group 88 | -------------------------------------------------------------------------------- /documentation/docs/contribute/01-how-to-start-contributing.md: -------------------------------------------------------------------------------- 1 | # How to start contributing 2 | 3 | Welcome! We are glad that you want to contribute to our project! 💖 4 | 5 | This project accepts contributions via Github pull requests. 6 | 7 | This document outlines the process to help get your contribution accepted. 8 | 9 | There are many ways to contribute: 10 | 11 | * Suggest [features](https://github.com/equinor/template-fastapi-react/issues/new?assignees=&labels=type%3A+%3Abulb%3A+feature+request&template=feature-request.md&title=) 12 | * Suggest [changes](https://github.com/equinor/template-fastapi-react/issues/new?assignees=&labels=type%3A+%3Awrench%3A+maintenance&template=code-maintenance.md&title=) 13 | * Report [bugs](https://github.com/equinor/template-fastapi-react/issues/new?assignees=&labels=type%3A+%3Abug+bug&template=bug-report.md&title=) 14 | 15 | You can start by looking through the [good first issues](https://github.com/equinor/template-fastapi-react/issues?q=is%3Aopen+is%3Aissue+label%3A%22%3Ahatching_chick%3A+good+first+issue%22). 16 | 17 | ## Fork the repository 18 | 19 | In general, we follow the ["fork-and-pull" Git workflow](https://github.com/susam/gitpr). 20 | 21 | Here's a quick guide: 22 | 23 | 1. Create your own fork of the repository 24 | 2. Clone the project to your machine 25 | 3. To keep track of the original repository add another remote named upstream 26 | ```shell 27 | git remote add upstream git@github.com:equinor/template-fastapi-react.git 28 | ``` 29 | 4. Create a branch locally with a succinct but descriptive name and prefixed with change type. 30 | ```shell 31 | git checkout -b feature/my-new-feature 32 | ``` 33 | 5. Make the changes in the created branch. 34 | 6. Add and run tests for your changes (we only take pull requests with passing tests). 35 | ```shell 36 | docker compose run --rm api pytest 37 | docker compose run --rm web yarn test 38 | ``` 39 | 7. Add the changed files 40 | ```shell 41 | git add path/to/filename 42 | ``` 43 | 8. Commit your changes using the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) formatting for the commit messages. 44 | ```shell 45 | git commit -m "conventional commit formatted message" 46 | ``` 47 | 9. Before you send the pull request, be sure to rebase onto the upstream source. This ensures your code is running on the latest available code. 48 | ```shell 49 | git fetch upstream 50 | git rebase upstream/main 51 | ``` 52 | 10. Push to your fork. 53 | ```shell 54 | git push origin feature/my-new-feature 55 | ``` 56 | 11. Submit a pull request to the original repository (via Github interface). Please provide us with some explanation of why you made the changes you made. For new features make sure to explain a standard use case to us. 57 | 58 | That's it... thank you for your contribution! 59 | 60 | After your pull request is merged, you can safely delete your branch. 61 | 62 | ## Code review process 63 | 64 | The core team looks at pull requests on a regular basis. After feedback has been given we expect responses within three weeks. After three weeks we may close the pull request if it isn't showing any activity. 65 | -------------------------------------------------------------------------------- /radixconfig.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: radix.equinor.com/v1 2 | kind: RadixApplication 3 | metadata: 4 | name: template-fastapi-react 5 | spec: 6 | build: 7 | useBuildKit: true 8 | useBuildCache: true 9 | environments: 10 | - name: prod 11 | - name: staging 12 | build: 13 | from: main 14 | - name: dev 15 | build: 16 | from: main 17 | components: 18 | - name: api 19 | image: ghcr.io/equinor/template-fastapi-react/api:{imageTagName} 20 | alwaysPullImageOnDeploy: true 21 | resources: 22 | requests: 23 | memory: "256Mi" 24 | cpu: "100m" 25 | limits: 26 | memory: "4Gi" 27 | cpu: "4000m" 28 | environmentConfig: 29 | - environment: prod 30 | imageTagName: latest 31 | horizontalScaling: 32 | minReplicas: 1 33 | maxReplicas: 4 34 | - environment: staging 35 | imageTagName: latest 36 | horizontalScaling: 37 | minReplicas: 1 38 | maxReplicas: 2 39 | - environment: dev 40 | imageTagName: latest 41 | horizontalScaling: 42 | minReplicas: 1 43 | maxReplicas: 1 44 | secrets: 45 | - SECRET_KEY 46 | - MONGODB_PASSWORD 47 | - APPINSIGHTS_CONSTRING 48 | variables: 49 | LOGGING_LEVEL: "debug" 50 | AUTH_ENABLED: "True" 51 | ENVIRONMENT: production 52 | OAUTH_WELL_KNOWN: https://login.microsoftonline.com/3aa4a235-b6e2-48d5-9195-7fcf05b459b0/v2.0/.well-known/openid-configuration 53 | OAUTH_TOKEN_ENDPOINT: https://login.microsoftonline.com/3aa4a235-b6e2-48d5-9195-7fcf05b459b0/oauth2/v2.0/token 54 | OAUTH_AUTH_ENDPOINT: https://login.microsoftonline.com/3aa4a235-b6e2-48d5-9195-7fcf05b459b0/oauth2/v2.0/authorize 55 | OAUTH_CLIENT_ID: 4a761bec-628d-4c4b-860a-4903cbecc963 56 | OAUTH_AUDIENCE: api://4a761bec-628d-4c4b-860a-4903cbecc963 57 | OAUTH_AUTH_SCOPE: api://4a761bec-628d-4c4b-860a-4903cbecc963/api 58 | MONGODB_USERNAME: root 59 | MONGODB_DATABASE: test 60 | ports: 61 | - name: rest 62 | port: 5000 63 | publicPort: rest 64 | 65 | - name: proxy 66 | image: ghcr.io/equinor/template-fastapi-react/nginx:{imageTagName} 67 | alwaysPullImageOnDeploy: true 68 | environmentConfig: 69 | - environment: prod 70 | imageTagName: latest 71 | - environment: staging 72 | imageTagName: latest 73 | - environment: dev 74 | imageTagName: latest 75 | ports: 76 | - name: nginx 77 | port: 8080 78 | publicPort: nginx 79 | 80 | - name: db 81 | image: bitnami/mongodb:5.0.12 82 | alwaysPullImageOnDeploy: true 83 | command: --auth --quiet 84 | variables: 85 | MONGODB_USERNAME: root 86 | MONGODB_DATABASE: test 87 | secrets: 88 | - MONGODB_PASSWORD 89 | - MONGODB_ROOT_PASSWORD 90 | ports: 91 | - name: dbport 92 | port: 27017 93 | publicPort: dbport 94 | 95 | dnsAppAlias: 96 | environment: prod 97 | component: proxy 98 | -------------------------------------------------------------------------------- /.github/workflows/publish-image.yaml: -------------------------------------------------------------------------------- 1 | name: "Publish container images" 2 | on: 3 | workflow_dispatch: 4 | workflow_call: # Workflow is meant to be called from another workflow, with the image tag as input 5 | inputs: 6 | image-tags: 7 | description: "Which tag to give the images. Supports multiple tags if comma separated, ie 'tag1,tag2'" 8 | required: true 9 | type: string 10 | secrets: 11 | CR_SECRET: 12 | description: "Secret to authenticate if using an other container registry than Github" 13 | required: false 14 | 15 | env: 16 | IMAGE_REGISTRY: ghcr.io 17 | REGISTRY_USER: $GITHUB_ACTOR 18 | API_IMAGE: ghcr.io/equinor/template-fastapi-react/api 19 | NGINX_IMAGE: ghcr.io/equinor/template-fastapi-react/nginx 20 | 21 | jobs: 22 | build-and-publish-nginx-main: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v5 26 | with: 27 | fetch-depth: 2 28 | 29 | - name: "Login to image registry" 30 | run: echo ${{ secrets.GITHUB_TOKEN }} | docker login $IMAGE_REGISTRY -u $GITHUB_ACTOR --password-stdin 31 | 32 | - name: "Build web" 33 | run: | 34 | docker pull $NGINX_IMAGE 35 | printf "$(git log -n 1 --format=format:'hash: %h%ndate: %cs%nrefs: %d' --decorate=short --decorate-refs=refs/tags | sed 's/ (tag: \([^\s]*\))/\1/')" > ./web/public/version.txt 36 | docker build \ 37 | --build-arg AUTH_ENABLED=1 \ 38 | --build-arg AUTH_SCOPE=api://4a761bec-628d-4c4b-860a-4903cbecc963/api \ 39 | --build-arg CLIENT_ID=4a761bec-628d-4c4b-860a-4903cbecc963 \ 40 | --build-arg TENANT_ID=3aa4a235-b6e2-48d5-9195-7fcf05b459b0 \ 41 | --cache-from ${NGINX_IMAGE} \ 42 | --tag ${NGINX_IMAGE} \ 43 | --target nginx-prod \ 44 | ./web 45 | 46 | - name: "Publish web" 47 | run: | 48 | IFS=',' 49 | for IMAGE_TAG in $(echo ${{ inputs.image-tags }}) 50 | do 51 | echo "Tagging with $IMAGE_TAG" 52 | docker tag $NGINX_IMAGE $NGINX_IMAGE:$IMAGE_TAG 53 | docker push $NGINX_IMAGE:$IMAGE_TAG 54 | done 55 | 56 | build-and-publish-api-main: 57 | runs-on: ubuntu-latest 58 | steps: 59 | - uses: actions/checkout@v5 60 | with: 61 | fetch-depth: 2 62 | 63 | - name: "Login to image registry" 64 | run: echo ${{ secrets.GITHUB_TOKEN }} | docker login $IMAGE_REGISTRY -u $GITHUB_ACTOR --password-stdin 65 | 66 | - name: "Build API" 67 | run: | 68 | docker pull $API_IMAGE 69 | printf "$(git log -n 1 --format=format:'hash: %h%ndate: %cs%nrefs: %d' --decorate=short --decorate-refs=refs/tags | sed 's/ (tag: \([^\s]*\))/\1/')" > ./api/src/version.txt 70 | docker build --cache-from $API_IMAGE --target prod --tag $API_IMAGE ./api 71 | 72 | - name: "Publish API" 73 | run: | 74 | IFS=',' 75 | for IMAGE_TAG in $(echo ${{ inputs.image-tags }}) 76 | do 77 | echo "Tagging with $IMAGE_TAG" 78 | docker tag $API_IMAGE $API_IMAGE:$IMAGE_TAG 79 | docker push $API_IMAGE:$IMAGE_TAG 80 | done 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Dependency directories 12 | node_modules 13 | web/.pnp 14 | .pnp.js 15 | web/package-lock.json 16 | 17 | # Testing 18 | /coverage 19 | /*/coverage 20 | 21 | # Editors 22 | .idea/ 23 | 24 | # misc 25 | .DS_Store 26 | .env.local 27 | .env.development.local 28 | .env.test.local 29 | .env.production.local 30 | 31 | npm-debug.log* 32 | yarn-debug.log* 33 | yarn-error.log* 34 | 35 | # Distribution / packaging 36 | .Python 37 | build/ 38 | develop-eggs/ 39 | dist/ 40 | downloads/ 41 | eggs/ 42 | .eggs/ 43 | lib/ 44 | lib64/ 45 | parts/ 46 | sdist/ 47 | var/ 48 | wheels/ 49 | pip-wheel-metadata/ 50 | share/python-wheels/ 51 | *.egg-info/ 52 | .installed.cfg 53 | *.egg 54 | MANIFEST 55 | 56 | # PyInstaller 57 | # Usually these files are written by a python script from a template 58 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 59 | *.manifest 60 | *.spec 61 | 62 | # Installer logs 63 | pip-log.txt 64 | pip-delete-this-directory.txt 65 | 66 | # Interrogate 67 | api/interrogate_badge.svg 68 | 69 | # Unit test / coverage reports 70 | htmlcov/ 71 | .tox/ 72 | .nox/ 73 | .coverage 74 | .coverage.* 75 | .cache 76 | nosetests.xml 77 | coverage.xml 78 | *.cover 79 | *.py,cover 80 | .hypothesis/ 81 | .pytest_cache/ 82 | 83 | # Translations 84 | *.mo 85 | *.pot 86 | 87 | # Django stuff: 88 | *.log 89 | local_settings.py 90 | db.sqlite3 91 | db.sqlite3-journal 92 | 93 | # Flask stuff: 94 | instance/ 95 | .webassets-cache 96 | 97 | # Scrapy stuff: 98 | .scrapy 99 | 100 | # Sphinx documentation 101 | docs/_build/ 102 | 103 | # PyBuilder 104 | target/ 105 | 106 | # Jupyter Notebook 107 | .ipynb_checkpoints 108 | 109 | # IPython 110 | profile_default/ 111 | ipython_config.py 112 | 113 | # pyenv 114 | .python-version 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | /*/.venv 130 | env/ 131 | venv/ 132 | ENV/ 133 | env.bak/ 134 | venv.bak/ 135 | /dist 136 | 137 | # Spyder project settings 138 | .spyderproject 139 | .spyproject 140 | 141 | # Rope project settings 142 | .ropeproject 143 | 144 | # mkdocs documentation 145 | /site 146 | 147 | # mypy 148 | .mypy_cache/ 149 | .dmypy.json 150 | dmypy.json 151 | 152 | # Pyre type checker 153 | .pyre/ 154 | .idea 155 | data 156 | 157 | # Yarn stuff 158 | web/.yarn/* 159 | !web/.yarn/cache 160 | !web/.yarn/patches 161 | !web/.yarn/plugins 162 | !web/.yarn/releases 163 | !web/.yarn/sdks 164 | !web/.yarn/versions 165 | !web/.yarn/**/lib/ 166 | 167 | # Asdf package manager 168 | /*/.tool-versions 169 | 170 | # Docusaurus 171 | .docusaurus 172 | .cache-loader 173 | -------------------------------------------------------------------------------- /api/src/tests/integration/features/todo/test_todo_feature.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from starlette.status import ( 3 | HTTP_200_OK, 4 | HTTP_404_NOT_FOUND, 5 | HTTP_422_UNPROCESSABLE_ENTITY, 6 | ) 7 | from starlette.testclient import TestClient 8 | 9 | from data_providers.clients.client_interface import ClientInterface 10 | 11 | 12 | class TestTodo: 13 | @pytest.fixture(autouse=True) 14 | def setup_database(self, test_client: ClientInterface): 15 | test_client.insert_many( 16 | [ 17 | {"_id": "1", "id": "1", "title": "title 1", "user_id": "nologin"}, 18 | {"_id": "2", "id": "2", "title": "title 2", "user_id": "nologin"}, 19 | ] 20 | ) 21 | 22 | def test_get_todo_all(self, test_app: TestClient): 23 | response = test_app.get("/todos") 24 | items = response.json() 25 | 26 | assert response.status_code == HTTP_200_OK 27 | assert len(items) == 2 28 | assert items[0]["id"] == "1" 29 | assert items[0]["title"] == "title 1" 30 | assert items[1]["id"] == "2" 31 | assert items[1]["title"] == "title 2" 32 | 33 | def test_get_todo_by_id(self, test_app: TestClient): 34 | response = test_app.get("/todos/1") 35 | 36 | assert response.status_code == HTTP_200_OK 37 | assert response.json()["id"] == "1" 38 | assert response.json()["title"] == "title 1" 39 | 40 | def test_get_todo_should_return_not_found(self, test_app: TestClient): 41 | response = test_app.get("/todos/unknown") 42 | assert response.status_code == HTTP_404_NOT_FOUND 43 | 44 | def test_add_todo(self, test_app: TestClient): 45 | response = test_app.post("/todos", json={"title": "title 3"}) 46 | item = response.json() 47 | 48 | assert response.status_code == HTTP_200_OK 49 | assert item["title"] == "title 3" 50 | 51 | def test_add_todo_should_return_unprocessable_when_invalid_entity(self, test_app: TestClient): 52 | response = test_app.post("/todos", json=None) 53 | 54 | assert response.status_code == HTTP_422_UNPROCESSABLE_ENTITY 55 | 56 | def test_update_todo(self, test_app): 57 | response = test_app.put("/todos/1", json={"title": "title 1 updated", "is_completed": False}) 58 | 59 | assert response.status_code == HTTP_200_OK 60 | assert response.json()["success"] 61 | 62 | def test_update_todo_should_return_not_found(self, test_app): 63 | response = test_app.put("/todos/unknown", json={"title": "something", "is_completed": False}) 64 | assert response.status_code == HTTP_404_NOT_FOUND 65 | 66 | def test_update_todo_should_return_unprocessable_when_invalid_entity(self, test_app: TestClient): 67 | response = test_app.put("/todos/1", json={"title": ""}) 68 | 69 | assert response.status_code == HTTP_422_UNPROCESSABLE_ENTITY 70 | 71 | def test_delete_todo(self, test_app: TestClient): 72 | response = test_app.delete("/todos/1") 73 | 74 | assert response.status_code == HTTP_200_OK 75 | assert response.json()["success"] 76 | 77 | def test_delete_todo_should_return_not_found(self, test_app: TestClient): 78 | response = test_app.delete("/todos/unknown") 79 | assert response.status_code == HTTP_404_NOT_FOUND 80 | -------------------------------------------------------------------------------- /api/src/data_providers/clients/mongodb/mongo_database_client.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from pymongo.cursor import Cursor 4 | from pymongo.database import Database 5 | from pymongo.errors import DuplicateKeyError 6 | from pymongo.mongo_client import MongoClient 7 | from pymongo.results import DeleteResult, InsertManyResult 8 | 9 | from common.exceptions import NotFoundException, ValidationException 10 | from config import config 11 | from data_providers.clients.client_interface import ClientInterface 12 | 13 | MONGO_CLIENT: MongoClient[dict[str, Any]] = MongoClient( 14 | host=config.MONGODB_HOSTNAME, 15 | port=config.MONGODB_PORT, 16 | username=config.MONGODB_USERNAME, 17 | password=config.MONGODB_PASSWORD, 18 | authSource="admin", 19 | tls=False, 20 | connectTimeoutMS=5000, 21 | serverSelectionTimeoutMS=5000, 22 | retryWrites=False, 23 | ) 24 | 25 | 26 | class MongoDatabaseClient(ClientInterface[dict, str]): 27 | def __init__(self, collection_name: str, database_name: str, client: MongoClient[dict[str, Any]] = MONGO_CLIENT): 28 | database: Database[dict[str, Any]] = client[database_name] 29 | self.database = database 30 | self.collection_name = collection_name 31 | self.collection = database[collection_name] 32 | 33 | def wipe_db(self) -> None: 34 | databases = self.database.client.list_database_names() 35 | databases_to_delete = [ 36 | database_name for database_name in databases if database_name not in ("admin", "config", "local") 37 | ] # Don't delete the mongo admin or local database 38 | for database_name in databases_to_delete: 39 | self.database.client.drop_database(database_name) 40 | 41 | def delete_collection(self) -> None: 42 | self.collection.drop() 43 | 44 | def create(self, document: dict[str, Any]) -> dict[str, Any]: 45 | try: 46 | result = self.collection.insert_one(document) 47 | return self.get(str(result.inserted_id)) 48 | except DuplicateKeyError: 49 | raise ValidationException(message=f"The document with id '{document['_id']}' already exists") 50 | 51 | def list_collection(self) -> list[dict[str, Any]]: 52 | return list(self.collection.find()) 53 | 54 | def get(self, uid: str) -> dict[str, Any]: 55 | document = self.collection.find_one(filter={"_id": uid}) 56 | if document is None: 57 | raise NotFoundException 58 | else: 59 | return dict(document) 60 | 61 | def update(self, uid: str, document: dict[str, Any]) -> dict[str, Any]: 62 | if self.collection.find_one(filter={"_id": uid}) is None: 63 | raise NotFoundException(extra={"uid": uid}) 64 | self.collection.replace_one({"_id": uid}, document) 65 | return self.get(uid) 66 | 67 | def delete(self, uid: str) -> bool: 68 | result = self.collection.delete_one(filter={"_id": uid}) 69 | return result.deleted_count > 0 70 | 71 | def find(self, filter: dict[str, Any]) -> Cursor[dict[str, Any]]: 72 | return self.collection.find(filter=filter) 73 | 74 | def find_one(self, filter: dict[str, Any]) -> dict[str, Any] | None: 75 | return self.collection.find_one(filter=filter) 76 | 77 | def insert_many(self, items: list[dict[str, Any]]) -> InsertManyResult: 78 | return self.collection.insert_many(items) 79 | 80 | def delete_many(self, filter: dict[str, Any]) -> DeleteResult: 81 | return self.collection.delete_many(filter) 82 | -------------------------------------------------------------------------------- /documentation/docs/contribute/development-guide/coding/extending-the-api/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Extending the API 6 | 7 | ## FastAPI 8 | 9 | [FastAPI](https://github.com/tiangolo/fastapi) is a high-performant REST API framework for Python. It's built on top of [Starlette](https://github.com/encode/starlette), an ASGI (Asynchronous Server Gateway Interface) implementation for Python, and it uses [Pydantic](https://github.com/pydantic/pydantic) for data validation. It can generate [OpenAPI](https://swagger.io/docs/specification/about/) documentation from your code and also produces a [Swagger UI](https://swagger.io/tools/swagger-ui/) that you can use to test your application. OpenAPI uses a subset of [JSON Schema](https://json-schema.org/) to describe APIs and define the validation rules of the 10 | API payloads and parameters. 11 | 12 | To run FastAPI applications, we use the process manager [uvicorn](https://github.com/encode/uvicorn). Check out the official [documentation](https://fastapi.tiangolo.com/deployment/server-workers/) for more details. 13 | 14 | ![FastAPI](fast-api.png) 15 | 16 | ## Codebase structure 17 | 18 | The API is grouped by features. 19 | 20 | ![Features](/img/features.png) 21 | 22 | The API has a feature-based folder structure following the principles of [Clean Architecture](../01-architecture.md). 23 | 24 | ``` 25 | ├── api/ 26 | │ └── src/ 27 | │ ├── common/ 28 | │ ├── entities/ 29 | │ ├── features/ 30 | │ │ ├── health_check/ 31 | │ │ ├── todo/ 32 | │ │ ├── whoami/ 33 | │ │ └── ... 34 | │ ├── data_providers/ 35 | │ └── tests/ 36 | │ ├── unit/ 37 | │ └── integration/ 38 | └── ... 39 | ``` 40 | 41 | - `common` contains shared code like authentication, exceptions, response decorator 42 | - [`entities`](02-adding-entities.md) contains all entities, enums, exceptions, interfaces, types and logic specific to the domain layer 43 | - [`features`](adding-features) contains use-cases (application logic), repository interfaces, and controllers 44 | - [`data providers`](adding-data-providers) contains classes for accessing external resources such as databases, file systems, web services, and repository implementations 45 | - `tests` contains unit and integrations tests 46 | 47 | ## Get started 48 | 49 | 1. Create the domain model by [adding entities](02-adding-entities.md) 50 | 2. Extend the API by [adding features](adding-features) 51 | * Add a [use case](adding-features/02-use-cases.md) to handle application logic 52 | * Add a [controller](adding-features/01-controllers.md) to handle API requests 53 | * Add an endpoint to the controller that executes the use case 54 | 3. Add a data provider, [repository interface](adding-data-providers/02-repository-interfaces.md) and [repository](adding-data-providers/03-repositories.md) to handle communication to external services such as databases. 55 | 56 | 57 | :::note 58 | 59 | Entities and data providers can be shared between features (the entrypoints and use-cases). 60 | 61 | ::: 62 | 63 | 64 | ## Configuration 65 | 66 | All configuration parameters are expected to be environment variables, and are defined in this file `api/src/config.py`. 67 | 68 | ```mdx-code-block 69 | import CodeBlock from '@theme/CodeBlock'; 70 | import auth from '!!raw-loader!@site/../api/src/config.py'; 71 | 72 | {auth} 73 | ``` 74 | 75 | See [configuration](../../../../about/running/configure) for a description of the different configuration options available. 76 | -------------------------------------------------------------------------------- /IaC/app-registration.bicep: -------------------------------------------------------------------------------- 1 | extension microsoftGraph 2 | param applicationName string = 'template-fastapi-react' 3 | param repositoryName string = 'template-fastapi-react' 4 | 5 | // The Entra ID application 6 | // Resource format https://learn.microsoft.com/en-us/graph/templates/reference/applications?view=graph-bicep-1.0 7 | resource app 'Microsoft.Graph/applications@v1.0' = { 8 | displayName: '${applicationName}' 9 | signInAudience: 'AzureADMyOrg' 10 | uniqueName: '${applicationName}' 11 | spa: { 12 | // The callback URL is the URL that the user is redirected to after the login, 13 | // and it contains the URL of the application that is registered in Radix and localhost for doing development. 14 | redirectUris: [ 15 | // Development 16 | 'https://proxy-${applicationName}-dev.radix.equinor.com/api/docs/oauth2-redirect' 17 | 'https://proxy-${applicationName}-dev.radix.equinor.com' 18 | 'https://proxy-${applicationName}-dev.radix.equinor.com/' 19 | // Staging 20 | 'https://proxy-${applicationName}-staging.radix.equinor.com/api/docs/oauth2-redirect' 21 | 'https://proxy-${applicationName}-staging.radix.equinor.com' 22 | 'https://proxy-${applicationName}-staging.radix.equinor.com/' 23 | // Production 24 | 'https://${applicationName}.app.radix.equinor.com/api/docs/oauth2-redirect' 25 | 'https://proxy-${applicationName}-prod.radix.equinor.com/' 26 | 'https://${applicationName}.app.radix.equinor.com/' 27 | 'https://${applicationName}.app.radix.equinor.com' 28 | // For development 29 | 'http://localhost/api/docs/oauth2-redirect' 30 | 'http://localhost/' 31 | 'http://localhost:5000/docs/oauth2-redirect' 32 | ] 33 | } 34 | api: { 35 | // In version 2 the audience is always the client id, and does not contain the api:// in the decoded JWT. 36 | // It is important to know this because the API expects a JWT token with a specific signature for validation, 37 | // and this is specified in the configuration settings and must match. 38 | requestedAccessTokenVersion: 2 39 | // To allow OpenAPI and clients to talk to the API, we need to add the scope to the API. 40 | oauth2PermissionScopes: [ 41 | { 42 | id: '31a61854-0d6d-4c60-918b-efffd4fac373' 43 | adminConsentDescription: 'Allow users to access the API' 44 | adminConsentDisplayName: 'Read' 45 | isEnabled: true 46 | type: 'User' 47 | userConsentDescription: 'Access the API' 48 | userConsentDisplayName: 'Access the API' 49 | value: 'api${app.appId}' 50 | } 51 | ] 52 | } 53 | appRoles: [ 54 | { 55 | id: '31a61854-0d6d-4c60-918b-efffd4fac379' 56 | allowedMemberTypes: [ 57 | 'User' 58 | 'Application' 59 | ] 60 | description: '${applicationName} administrators. Access to all fields. Permission to edit admin values.' 61 | displayName: 'Admin' 62 | isEnabled: true 63 | value: 'admin' 64 | } 65 | ] 66 | // Resource format https://learn.microsoft.com/en-us/graph/templates/reference/federatedidentitycredentials?view=graph-bicep-1.0 67 | resource githubFic 'federatedIdentityCredentials' = { 68 | name: '${app.uniqueName}/githubFic' 69 | audiences: [ 70 | 'api://AzureADTokenExchange' 71 | ] 72 | description: 'Federated Identity Credentials for Github Actions to access Entra protected resources' 73 | issuer: 'https://token.actions.githubusercontent.com' 74 | // Subject is checked before issuing an Entra ID access token to access Azure resources. 75 | // GitHub Actions subject examples can be found in https://docs.github.com/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#example-subject-claims 76 | subject: 'repo:equinor/${repositoryName}:ref:refs/heads/main' 77 | } 78 | } 79 | 80 | // The Service Principle (or Enterprise App) 81 | resource appSP 'Microsoft.Graph/servicePrincipals@v1.0' = { 82 | appId: app.appId 83 | displayName: '${applicationName}' 84 | 85 | } 86 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: "Test" 2 | on: 3 | # Workflow dispatch is used for manual triggers 4 | workflow_dispatch: 5 | # Workflow call is used for called from another workflow 6 | workflow_call: 7 | secrets: 8 | CR_SECRET: 9 | description: "Secret to authenticate if using an other container registry than Github" 10 | required: false 11 | 12 | env: 13 | IMAGE_REGISTRY: ghcr.io 14 | REGISTRY_USER: $GITHUB_ACTOR 15 | API_IMAGE: ghcr.io/equinor/template-fastapi-react/api 16 | WEB_IMAGE: ghcr.io/equinor/template-fastapi-react/web 17 | 18 | jobs: 19 | api-unit-tests: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v5 23 | 24 | - name: Login to image registry 25 | run: echo ${{ secrets.GITHUB_TOKEN }} | docker login $IMAGE_REGISTRY -u $GITHUB_ACTOR --password-stdin 26 | 27 | - name: Build API image 28 | run: | 29 | docker pull $API_IMAGE 30 | docker build --target development --tag api-development ./api # TODO: --cache-from $API_IMAGE 31 | - name: Pytest Unit tests 32 | run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm api pytest --unit 33 | 34 | api-integration-tests: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v5 38 | 39 | - name: Login to image registry 40 | run: echo ${{ secrets.GITHUB_TOKEN }} | docker login $IMAGE_REGISTRY -u $GITHUB_ACTOR --password-stdin 41 | 42 | - name: Build API image 43 | run: | 44 | docker pull $API_IMAGE 45 | docker build --target development --tag api-development ./api # TODO: --cache-from $API_IMAGE 46 | - name: BDD Integration tests 47 | if: ${{ false }} # disable for now 48 | run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run api behave 49 | 50 | - name: Pytest Integration tests 51 | run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm api pytest --integration 52 | 53 | web-tests: 54 | runs-on: ubuntu-latest 55 | if: ${{ false }} # disable for now as they do not currently work 56 | steps: 57 | - uses: actions/checkout@v5 58 | 59 | - name: Login to image registry 60 | run: echo ${{ secrets.GITHUB_TOKEN }} | docker login $IMAGE_REGISTRY -u $GITHUB_ACTOR --password-stdin 61 | 62 | - name: Build Web Image 63 | run: | 64 | docker pull $WEB_IMAGE 65 | docker build --cache-from $WEB_IMAGE --target development --tag web-dev ./web 66 | 67 | - name: Run Web tests 68 | if: ${{ false }} # disable for now as they do not currently work 69 | run: docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm web yarn test 70 | 71 | docs-tests: 72 | name: test-docs 73 | runs-on: ubuntu-latest 74 | 75 | steps: 76 | # If you know your docs does not rely on anything outside of the documentation folder, the commented out code below can be used to only test the docs build if the documentation folder changes. 77 | - name: Checkout GitHub Action 78 | uses: actions/checkout@v5 79 | # with: 80 | # fetch-depth: 0 81 | 82 | # - name: "Get number of changed documentation files" 83 | # id: docs-changes 84 | # shell: bash 85 | # run: echo "changes=$(git diff --name-only $(git merge-base HEAD origin/main) HEAD | grep documentation/ | wc -l)" >> $GITHUB_OUTPUT 86 | 87 | - name: Setup node 88 | # if: steps.docs-changes.outputs.changes > 0 89 | uses: actions/setup-node@v5 90 | with: 91 | node-version: 20 92 | cache: yarn 93 | cache-dependency-path: documentation/yarn.lock 94 | 95 | - name: Install dependencies and build website 96 | # if: steps.docs-changes.outputs.changes > 0 97 | run: | 98 | cd documentation 99 | yarn install --frozen-lockfile 100 | yarn build 101 | -------------------------------------------------------------------------------- /api/src/common/exceptions.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Any 3 | 4 | from pydantic import BaseModel 5 | from starlette import status as request_status 6 | 7 | 8 | class ExceptionSeverity(Enum): 9 | WARNING = 1 10 | ERROR = 2 11 | CRITICAL = 3 12 | 13 | 14 | # Pydantic models can not inherit from "Exception", but we use it for OpenAPI spec 15 | class ErrorResponse(BaseModel): 16 | status: int = 500 17 | type: str = "ApplicationException" 18 | message: str = "The requested operation failed" 19 | debug: str = "An unknown and unhandled exception occurred in the API" 20 | extra: dict[str, Any] | None = None 21 | 22 | 23 | class ApplicationException(Exception): 24 | status: int = 500 25 | severity: ExceptionSeverity = ExceptionSeverity.ERROR 26 | type: str = "ApplicationException" 27 | message: str = "The requested operation failed" 28 | debug: str = "An unknown and unhandled exception occurred in the API" 29 | extra: dict[str, Any] | None = None 30 | 31 | def __init__( 32 | self, 33 | message: str = "The requested operation failed", 34 | debug: str = "An unknown and unhandled exception occurred in the API", 35 | extra: dict[str, Any] | None = None, 36 | status: int = 500, 37 | severity: ExceptionSeverity = ExceptionSeverity.ERROR, 38 | ): 39 | self.status = status 40 | self.type = self.__class__.__name__ 41 | self.message = message 42 | self.debug = debug 43 | self.extra = extra 44 | self.severity = severity 45 | 46 | def to_dict(self) -> dict[str, int | str | dict[str, Any] | None]: 47 | return { 48 | "status": self.status, 49 | "type": self.type, 50 | "message": self.message, 51 | "debug": self.debug, 52 | "extra": self.extra, 53 | } 54 | 55 | 56 | class MissingPrivilegeException(ApplicationException): 57 | def __init__( 58 | self, 59 | message: str = "You do not have the required permissions", 60 | debug: str = "Action denied because of insufficient permissions", 61 | extra: dict[str, Any] | None = None, 62 | ): 63 | super().__init__(message, debug, extra, request_status.HTTP_403_FORBIDDEN, severity=ExceptionSeverity.WARNING) 64 | self.type = self.__class__.__name__ 65 | 66 | 67 | class NotFoundException(ApplicationException): 68 | def __init__( 69 | self, 70 | message: str = "The requested resource could not be found", 71 | debug: str = "The requested resource could not be found", 72 | extra: dict[str, Any] | None = None, 73 | ): 74 | super().__init__(message, debug, extra, request_status.HTTP_404_NOT_FOUND) 75 | self.type = self.__class__.__name__ 76 | 77 | 78 | class BadRequestException(ApplicationException): 79 | def __init__( 80 | self, 81 | message: str = "Invalid data for the operation", 82 | debug: str = "Unable to complete the requested operation with the given input values.", 83 | extra: dict[str, Any] | None = None, 84 | ): 85 | super().__init__(message, debug, extra, request_status.HTTP_400_BAD_REQUEST) 86 | self.type = self.__class__.__name__ 87 | 88 | 89 | class ValidationException(ApplicationException): 90 | def __init__( 91 | self, 92 | message: str = "The received data is invalid", 93 | debug: str = "Values are invalid for requested operation.", 94 | extra: dict[str, Any] | None = None, 95 | ): 96 | super().__init__(message, debug, extra, request_status.HTTP_422_UNPROCESSABLE_ENTITY) 97 | self.type = self.__class__.__name__ 98 | 99 | 100 | class UnauthorizedException(ApplicationException): 101 | def __init__( 102 | self, 103 | message: str = "Token validation failed", 104 | debug: str = "Token was not valid for requested operation.", 105 | extra: dict[str, Any] | None = None, 106 | ): 107 | super().__init__(message, debug, extra, request_status.HTTP_401_UNAUTHORIZED) 108 | self.type = self.__class__.__name__ 109 | -------------------------------------------------------------------------------- /api/src/tests/integration/mock_authentication.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | from fastapi import Security 3 | 4 | from authentication.authentication import oauth2_scheme 5 | from authentication.models import User 6 | from common.exceptions import UnauthorizedException 7 | from config import config, default_user 8 | 9 | 10 | def get_mock_rsa_private_key() -> str: 11 | """ 12 | Used for testing. 13 | Generated with: 'openssl req -nodes -new -x509 -keyout server.key -out server.cert'. 14 | """ 15 | return """ 16 | -----BEGIN PRIVATE KEY----- 17 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDfsOW9ih/oBUwl 18 | LEH4t2C2GZeq3/dEXCkK54CNPZv979rir0nQQ5pLVcoohoVFe+QwC746xg8t7/YP 19 | cWHwcHXkRBgwRXHATJmD3MjXp/kCIoIUe2Qt8Or/j1BDhqpxkJcoBfiTt2kBecHc 20 | CgHmkQg5g0QGB0dTi+c3nUNk8HscxGU2jFSjFunlyVxHpujNfkA7m/hu5B12ggt6 21 | HclrOzdb2s/Lop017uEQ18+HJrja/b1+EVqdQfET0FFQu3cgr+b0PHjWYKHoUGA0 22 | 0Whpw3YjCfyr7aBRVQwOdBDxwJgWcozxCVBs+srjqscikj2KEE4MtsLgtz/FqK/T 23 | ibwdhteDAgMBAAECggEAb19uI37AAA+TJ/bvKdxzpHb9krBMNpcEQE+fK7N/FWH0 24 | w2SvBaiDC/s82gyQElZq+JkAL9co+6A8DNhRARudNvfIa1BIIIyC6qpkvSr+yddQ 25 | mM4OxOjsuC0ss1I7TqvE9sJyT2nEOF3c7ad15sxTIf9/QNki5DAGASSlx34MbfdU 26 | VUvkmY6HDFFMSd5G9JedEpBNKnY2+ZPQk4SHpm8IDiJ1FxkDWu9kTxJFetTDpSZ6 27 | OEyvbwGSzNkWRkmprv6X9uF7JzQ/BE7PxDrWQPvbjMye63WN4/y/wuKDxMzCM2bl 28 | vWhQPQmEyYJt+DhobtiNQr+zY1rrtKHNtfxAHuY8wQKBgQD3yiIHfHoJWfRdOhkz 29 | Q6cdbuxm1B9vij4M95kGQmv39sNCyPeu860PQ8nmTuDyh7VmuXuuC0DaZiVfblod 30 | cU27y9EBSdFAI613t8VDqgJIj5ghoFmYuSYWbGJnNKK0xgecQwRyDhzCzCr5jjtV 31 | ydPHKv3WA6pKlOBULU7ZmN7wEQKBgQDnGllfGxItzW8H6MTrai/fI2JK1PCicEzD 32 | qGdlKYqsfF+gRFGA8IAujB6+Q8Gq8e8j3CVD6cm3XhFaC+5FrH5/yLVjLTnNDzOk 33 | YYlGsLS7FrMF3ZV+eURZMiRwrPdr10onDAYcaw+p8/MK3Rybl57ElaT7FkYImptu 34 | hwFMOFDiUwKBgFzsZ6CJFLbnDhXcENFBwKzwCSVyzSsmG6j/PVq0lArUdltYRFJO 35 | vYqo8FE3KXKqY+PXEUOuoq6EeeV028SI1g7kG0gxZ5B3ELmBqC981QhjGTkbCh6U 36 | 6GymTqzHd3D1hqsaEtO26SBAMqmNpkDAxHO/cpvMmhMIC6xlpVlC0/ARAoGALwCD 37 | 5rzpwJkEmPY1fq+1FsvqhM+0NUVjx3Nru/5r7tLI3B6o+PFxEIZ9BjNfozXbbk6q 38 | 4Zod5YZjPw4oItGHVNPsWERtehA6b5dKxS7RQy/Fr062xedCCGYTVTtIgw1hTnm6 39 | kHMR133/E1mPJPH8X30T9eE80ykmrZ8Vm3vkr3MCgYEAjDtNvW/sZ/J1go5bDuzE 40 | SqNUf+yp+lcftsuzzx+AC9kyQA23LfZVcW9JLbAeWVxELF1s3unOBIwe98gk4LC9 41 | j9QyibKClixQO204dsxsNECCxHTnL2EUmV6zt/kmuLsRBico/85MF3aKcvljVuV7 42 | Ps2+z0zvD9eqCcQ4YrrqXGM= 43 | -----END PRIVATE KEY----- 44 | """ 45 | 46 | 47 | def get_mock_rsa_public_key() -> str: 48 | """ 49 | Used for testing. 50 | Convert cert to pub key with: 'openssl x509 -pubkey -noout < server.cert' 51 | """ 52 | return """ 53 | -----BEGIN PUBLIC KEY----- 54 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA37DlvYof6AVMJSxB+Ldg 55 | thmXqt/3RFwpCueAjT2b/e/a4q9J0EOaS1XKKIaFRXvkMAu+OsYPLe/2D3Fh8HB1 56 | 5EQYMEVxwEyZg9zI16f5AiKCFHtkLfDq/49QQ4aqcZCXKAX4k7dpAXnB3AoB5pEI 57 | OYNEBgdHU4vnN51DZPB7HMRlNoxUoxbp5clcR6bozX5AO5v4buQddoILeh3Jazs3 58 | W9rPy6KdNe7hENfPhya42v29fhFanUHxE9BRULt3IK/m9Dx41mCh6FBgNNFoacN2 59 | Iwn8q+2gUVUMDnQQ8cCYFnKM8QlQbPrK46rHIpI9ihBODLbC4Lc/xaiv04m8HYbX 60 | gwIDAQAB 61 | -----END PUBLIC KEY----- 62 | """ 63 | 64 | 65 | def get_mock_jwt_token(user: User = default_user) -> str: 66 | """ 67 | This function is for testing purposes only 68 | Used for behave testing 69 | """ 70 | # https://docs.microsoft.com/en-us/azure/active-directory/develop/id-tokens#claims-in-an-id-token 71 | payload = { 72 | "name": user.full_name, 73 | "preferred_username": user.email, 74 | "scp": "testing", 75 | "sub": user.user_id, 76 | "roles": user.roles, 77 | "iss": "mock-auth-server", 78 | "aud": "TEST", 79 | } 80 | # This absolutely returns a str, so this is possibly a mypy bug 81 | return jwt.encode(payload, get_mock_rsa_private_key(), algorithm="RS256") # type: ignore[no-any-return] 82 | 83 | 84 | def mock_auth_with_jwt(jwt_token: str = Security(oauth2_scheme)) -> User: 85 | if not config.AUTH_ENABLED: 86 | return default_user 87 | try: 88 | payload = jwt.decode(jwt_token, get_mock_rsa_public_key(), algorithms=["RS256"], audience="TEST") 89 | print(payload) 90 | user = User(user_id=payload["sub"], **payload) 91 | except jwt.exceptions.InvalidTokenError as error: 92 | raise UnauthorizedException from error 93 | if user is None: 94 | raise UnauthorizedException 95 | return user 96 | --------------------------------------------------------------------------------