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 |
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 |
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