mirror of
https://github.com/fastapi/full-stack-fastapi-template.git
synced 2026-11-03 20:36:27 +00:00
Compare commits
85 Commits
4d3d5e92c1
...
0.12.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 84c5a9e11a | |||
| 98fb606b57 | |||
| de2ed05b10 | |||
| ec86250a21 | |||
| 7f11815e43 | |||
| a2d2d18108 | |||
| 2d8fb3e4de | |||
| ddb3c34f19 | |||
| eae2d6701d | |||
| 6b9fa4113c | |||
| b55b3122ab | |||
| 6dbb2e8256 | |||
| 6d75b2d132 | |||
| 32ab6dd022 | |||
| cf1548a983 | |||
| c8f9069168 | |||
| 512e496e1f | |||
| fd0ca44d77 | |||
| af791805ac | |||
| 9a218cd74a | |||
| d85803bf64 | |||
| 781b283849 | |||
| c291285975 | |||
| b1abd91ba7 | |||
| e95e501951 | |||
| 111c5fad66 | |||
| 9d541082a3 | |||
| 8893c6807a | |||
| 1c5dd70308 | |||
| b5a664d510 | |||
| 23b0ba9bad | |||
| 74cf9ed832 | |||
| 4256e83fec | |||
| 85cd14d474 | |||
| 5069df32ea | |||
| f2035ef732 | |||
| b0ccf1e80e | |||
| de53c8fd35 | |||
| 375af70ee2 | |||
| 6c2122e37d | |||
| 2c28c6f7dc | |||
| a5aff58850 | |||
| 060795204b | |||
| 1a30f30e51 | |||
| dd15054da0 | |||
| bda0325818 | |||
| 2c7e51505e | |||
| 0625bcc8b0 | |||
| 4632845a39 | |||
| 029e929253 | |||
| 8a213ddae7 | |||
| 66f444a63a | |||
| 9c8983e2a5 | |||
| 2d04828d6a | |||
| 0137c3f2c3 | |||
| 75c388ba1c | |||
| d5b20172e4 | |||
| 9097b329d6 | |||
| 6a1c084f6e | |||
| 220bcb74aa | |||
| 34378100a8 | |||
| e5cdc73405 | |||
| 96c1147ee7 | |||
| d506ea4883 | |||
| a8f1ba0e70 | |||
| f8b7926d1c | |||
| de92a0d9fa | |||
| 750d3d0bc6 | |||
| 1b4d46c0b3 | |||
| 97f28a5905 | |||
| 752fc77bed | |||
| 24ff71fdac | |||
| af27db9a1f | |||
| 24db0e9e18 | |||
| 84908a6fdd | |||
| 546f18469c | |||
| daae6e1434 | |||
| 78c699079e | |||
| e80d92452e | |||
| 588dfd46e5 | |||
| eb9275a2f8 | |||
| c9e70d65c7 | |||
| 7d0d2a890e | |||
| 5b358ea6f4 | |||
| 1c82b25096 |
@@ -1 +0,0 @@
|
|||||||
{{ _copier_answers|to_json -}}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
import json
|
|
||||||
|
|
||||||
# Update the .env file with the answers from the .copier-answers.yml file
|
|
||||||
# without using Jinja2 templates in the .env file, this way the code works as is
|
|
||||||
# without needing Copier, but if Copier is used, the .env file will be updated
|
|
||||||
root_path = Path(__file__).parent.parent
|
|
||||||
answers_path = Path(__file__).parent / ".copier-answers.yml"
|
|
||||||
answers = json.loads(answers_path.read_text())
|
|
||||||
env_path = root_path / ".env"
|
|
||||||
env_content = env_path.read_text()
|
|
||||||
lines = []
|
|
||||||
for line in env_content.splitlines():
|
|
||||||
for key, value in answers.items():
|
|
||||||
upper_key = key.upper()
|
|
||||||
if line.startswith(f"{upper_key}="):
|
|
||||||
if " " in value:
|
|
||||||
content = f"{upper_key}={value!r}"
|
|
||||||
else:
|
|
||||||
content = f"{upper_key}={value}"
|
|
||||||
new_line = line.replace(line, content)
|
|
||||||
lines.append(new_line)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
lines.append(line)
|
|
||||||
env_path.write_text("\n".join(lines))
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
**/__pycache__
|
||||||
|
**/.venv
|
||||||
|
backend/htmlcov
|
||||||
|
frontend/blob-report
|
||||||
|
frontend/dist
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/playwright-report
|
||||||
|
frontend/test-results
|
||||||
|
node_modules
|
||||||
@@ -1,45 +1,18 @@
|
|||||||
# Domain
|
# Enable development behavior for commands that import the app directly
|
||||||
# This would be set to the production domain with an env var on deployment
|
FASTAPI_ENV=development
|
||||||
# used by Traefik to transmit traffic and acquire TLS certificates
|
|
||||||
DOMAIN=localhost
|
|
||||||
# To test the local Traefik config
|
|
||||||
# DOMAIN=localhost.tiangolo.com
|
|
||||||
|
|
||||||
# Used by the backend to generate links in emails to the frontend
|
|
||||||
FRONTEND_HOST=http://localhost:5173
|
|
||||||
# In staging and production, set this env var to the frontend host, e.g.
|
|
||||||
# FRONTEND_HOST=https://dashboard.example.com
|
|
||||||
|
|
||||||
# Environment: local, staging, production
|
|
||||||
ENVIRONMENT=local
|
|
||||||
|
|
||||||
PROJECT_NAME="Full Stack FastAPI Project"
|
PROJECT_NAME="Full Stack FastAPI Project"
|
||||||
STACK_NAME=full-stack-fastapi-project
|
|
||||||
|
|
||||||
# Backend
|
|
||||||
BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com"
|
|
||||||
SECRET_KEY=changethis
|
SECRET_KEY=changethis
|
||||||
FIRST_SUPERUSER=admin@example.com
|
FIRST_SUPERUSER=admin@example.com
|
||||||
FIRST_SUPERUSER_PASSWORD=changethis
|
FIRST_SUPERUSER_PASSWORD=changethis
|
||||||
|
|
||||||
# Emails
|
# Emails
|
||||||
SMTP_HOST=
|
SMTP_HOST=localhost
|
||||||
SMTP_USER=
|
|
||||||
SMTP_PASSWORD=
|
|
||||||
EMAILS_FROM_EMAIL=info@example.com
|
EMAILS_FROM_EMAIL=info@example.com
|
||||||
SMTP_TLS=True
|
SMTP_TLS=False
|
||||||
SMTP_SSL=False
|
SMTP_PORT=1025
|
||||||
SMTP_PORT=587
|
|
||||||
|
|
||||||
# Postgres
|
# Postgres
|
||||||
POSTGRES_SERVER=localhost
|
|
||||||
POSTGRES_PORT=5432
|
|
||||||
POSTGRES_DB=app
|
|
||||||
POSTGRES_USER=postgres
|
|
||||||
POSTGRES_PASSWORD=changethis
|
POSTGRES_PASSWORD=changethis
|
||||||
|
DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@localhost:5432/app
|
||||||
SENTRY_DSN=
|
|
||||||
|
|
||||||
# Configure these with your own Docker registry images
|
|
||||||
DOCKER_IMAGE_BACKEND=backend
|
|
||||||
DOCKER_IMAGE_FRONTEND=frontend
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
!backend/app/frontend/
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
docs:
|
|
||||||
- all:
|
|
||||||
- changed-files:
|
|
||||||
- any-glob-to-any-file:
|
|
||||||
- '**/*.md'
|
|
||||||
- all-globs-to-all-files:
|
|
||||||
- '!frontend/**'
|
|
||||||
- '!backend/**'
|
|
||||||
- '!.github/**'
|
|
||||||
- '!scripts/**'
|
|
||||||
- '!.gitignore'
|
|
||||||
- '!.pre-commit-config.yaml'
|
|
||||||
|
|
||||||
internal:
|
|
||||||
- all:
|
|
||||||
- changed-files:
|
|
||||||
- any-glob-to-any-file:
|
|
||||||
- .github/**
|
|
||||||
- scripts/**
|
|
||||||
- .gitignore
|
|
||||||
- .pre-commit-config.yaml
|
|
||||||
- all-globs-to-all-files:
|
|
||||||
- '!./**/*.md'
|
|
||||||
- '!frontend/**'
|
|
||||||
- '!backend/**'
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
auto-labels:
|
||||||
|
docs:
|
||||||
|
- exclude: frontend/**
|
||||||
|
- exclude: backend/**
|
||||||
|
- exclude: .github/**
|
||||||
|
- exclude: scripts/**
|
||||||
|
- '**/*.md'
|
||||||
|
internal:
|
||||||
|
- .github/**
|
||||||
|
- scripts/**
|
||||||
|
- .gitignore
|
||||||
|
- .pre-commit-config.yaml
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
workflows:
|
||||||
|
- .github/workflows/pre-commit.yml
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
workflows:
|
||||||
|
- .github/workflows/bump-pre-commit-hooks.yml
|
||||||
|
- .github/workflows/prepare-release.yml
|
||||||
@@ -12,6 +12,7 @@ permissions: {}
|
|||||||
jobs:
|
jobs:
|
||||||
add-to-project:
|
add-to-project:
|
||||||
name: Add to project
|
name: Add to project
|
||||||
|
if: github.repository_owner == 'fastapi'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -12,21 +12,23 @@ jobs:
|
|||||||
if: github.repository_owner == 'fastapi'
|
if: github.repository_owner == 'fastapi'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Dump GitHub context
|
- name: Dump GitHub context
|
||||||
env:
|
env:
|
||||||
GITHUB_CONTEXT: ${{ toJson(github) }}
|
GITHUB_CONTEXT: ${{ toJson(github) }}
|
||||||
run: echo "$GITHUB_CONTEXT"
|
run: echo "$GITHUB_CONTEXT"
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.LATEST_CHANGES }}
|
persist-credentials: false
|
||||||
persist-credentials: true
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version-file: ".python-version"
|
python-version-file: ".python-version"
|
||||||
- name: Setup uv
|
- name: Setup uv
|
||||||
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
with:
|
with:
|
||||||
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
||||||
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
||||||
@@ -36,9 +38,12 @@ jobs:
|
|||||||
uv.lock
|
uv.lock
|
||||||
- name: Bump pre-commit hooks
|
- name: Bump pre-commit hooks
|
||||||
run: uv run prek auto-update --freeze --cooldown-days 7
|
run: uv run prek auto-update --freeze --cooldown-days 7
|
||||||
|
- name: Get PR Submit token
|
||||||
|
id: pr-submit
|
||||||
|
uses: tiangolo/pr-submit@d802fdf59bde80bc3eb8bd3259f4cbeec63de4aa # 0.0.1
|
||||||
- name: Create pull request
|
- name: Create pull request
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.LATEST_CHANGES }}
|
GH_TOKEN: ${{ steps.pr-submit.outputs.token }}
|
||||||
BASE_BRANCH: ${{ github.event.repository.default_branch }}
|
BASE_BRANCH: ${{ github.event.repository.default_branch }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -46,12 +51,13 @@ jobs:
|
|||||||
echo "No pre-commit hook updates available"
|
echo "No pre-commit hook updates available"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "pr-submit[bot]"
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "pr-submit[bot]@users.noreply.github.com"
|
||||||
branch="bump-pre-commit-hooks"
|
branch="bump-pre-commit-hooks"
|
||||||
git switch -C "$branch"
|
git switch -C "$branch"
|
||||||
git add .pre-commit-config.yaml
|
git add .pre-commit-config.yaml
|
||||||
git commit -m "⬆ Bump pre-commit hooks"
|
git commit -m "⬆ Bump pre-commit hooks"
|
||||||
|
gh auth setup-git
|
||||||
git push --force origin "$branch"
|
git push --force origin "$branch"
|
||||||
if [ -z "$(gh pr list --head "$branch" --state open --json number --jq '.[].number')" ]; then
|
if [ -z "$(gh pr list --head "$branch" --state open --json number --jq '.[].number')" ]; then
|
||||||
gh pr create \
|
gh pr create \
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: Create Draft Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- closed
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create-draft-release:
|
||||||
|
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: read
|
||||||
|
steps:
|
||||||
|
- name: Dump GitHub context
|
||||||
|
env:
|
||||||
|
GITHUB_CONTEXT: ${{ toJson(github) }}
|
||||||
|
run: echo "$GITHUB_CONTEXT"
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.repository.default_branch }}
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version-file: ".python-version"
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
|
with:
|
||||||
|
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
||||||
|
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
||||||
|
version: "0.11.18"
|
||||||
|
- name: Extract release details
|
||||||
|
id: release-details
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
version="$(uv run python scripts/prepare_release.py current-version)"
|
||||||
|
uv run python scripts/prepare_release.py release-notes > draft-release-notes.md
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Create draft release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
VERSION: ${{ steps.release-details.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
gh release create "$VERSION" \
|
||||||
|
--draft \
|
||||||
|
--title "$VERSION" \
|
||||||
|
--notes-file draft-release-notes.md \
|
||||||
|
--target "$(git rev-parse HEAD)"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: Deploy with Docker Compose
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-docker-compose
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
# Do not deploy the template repository, only projects created from it
|
||||||
|
if: github.repository != 'fastapi/full-stack-fastapi-template'
|
||||||
|
runs-on: self-hosted
|
||||||
|
timeout-minutes: 30
|
||||||
|
env:
|
||||||
|
DOMAIN: ${{ vars.DOMAIN }}
|
||||||
|
PROJECT_NAME: ${{ vars.PROJECT_NAME }}
|
||||||
|
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||||
|
FIRST_SUPERUSER: ${{ vars.FIRST_SUPERUSER }}
|
||||||
|
FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
|
||||||
|
SMTP_HOST: ${{ vars.SMTP_HOST }}
|
||||||
|
SMTP_USER: ${{ vars.SMTP_USER }}
|
||||||
|
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
|
||||||
|
EMAILS_FROM_EMAIL: ${{ vars.EMAILS_FROM_EMAIL }}
|
||||||
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
|
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Build
|
||||||
|
run: docker compose -f compose.yml -f compose.deploy.yml build
|
||||||
|
- name: Prepare database
|
||||||
|
run: docker compose -f compose.yml -f compose.deploy.yml run --rm backend bash scripts/prestart.sh
|
||||||
|
- name: Start application
|
||||||
|
run: docker compose -f compose.yml -f compose.deploy.yml up -d
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
name: Deploy to Production
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types:
|
|
||||||
- published
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
environment: production
|
|
||||||
# Do not deploy in the main repository, only in user projects
|
|
||||||
if: github.repository_owner != 'fastapi'
|
|
||||||
runs-on:
|
|
||||||
- self-hosted
|
|
||||||
- production
|
|
||||||
env:
|
|
||||||
ENVIRONMENT: production
|
|
||||||
DOMAIN: ${{ secrets.DOMAIN_PRODUCTION }}
|
|
||||||
STACK_NAME: ${{ secrets.STACK_NAME_PRODUCTION }}
|
|
||||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
|
||||||
FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
|
|
||||||
FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
|
|
||||||
SMTP_HOST: ${{ secrets.SMTP_HOST }}
|
|
||||||
SMTP_USER: ${{ secrets.SMTP_USER }}
|
|
||||||
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
|
|
||||||
EMAILS_FROM_EMAIL: ${{ secrets.EMAILS_FROM_EMAIL }}
|
|
||||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
|
||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- run: docker compose -f compose.yml --project-name ${{ secrets.STACK_NAME_PRODUCTION }} build
|
|
||||||
- run: docker compose -f compose.yml --project-name ${{ secrets.STACK_NAME_PRODUCTION }} up -d
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
name: Deploy to Staging
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
environment: staging
|
|
||||||
# Do not deploy in the main repository, only in user projects
|
|
||||||
if: github.repository_owner != 'fastapi'
|
|
||||||
runs-on:
|
|
||||||
- self-hosted
|
|
||||||
- staging
|
|
||||||
env:
|
|
||||||
ENVIRONMENT: staging
|
|
||||||
DOMAIN: ${{ secrets.DOMAIN_STAGING }}
|
|
||||||
STACK_NAME: ${{ secrets.STACK_NAME_STAGING }}
|
|
||||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
|
||||||
FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
|
|
||||||
FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
|
|
||||||
SMTP_HOST: ${{ secrets.SMTP_HOST }}
|
|
||||||
SMTP_USER: ${{ secrets.SMTP_USER }}
|
|
||||||
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
|
|
||||||
EMAILS_FROM_EMAIL: ${{ secrets.EMAILS_FROM_EMAIL }}
|
|
||||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
|
||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- run: docker compose -f compose.yml --project-name ${{ secrets.STACK_NAME_STAGING }} build
|
|
||||||
- run: docker compose -f compose.yml --project-name ${{ secrets.STACK_NAME_STAGING }} up -d
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths-ignore:
|
||||||
|
- release-notes.md
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
# Do not deploy the template repository, only projects created from it
|
||||||
|
if: github.repository != 'fastapi/full-stack-fastapi-template'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Set up Bun
|
||||||
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.12
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: bun ci
|
||||||
|
- name: Build frontend
|
||||||
|
run: bun run --filter frontend build
|
||||||
|
env:
|
||||||
|
# Override frontend/.env so the deployed frontend uses the same origin as the API
|
||||||
|
VITE_API_URL: ""
|
||||||
|
- name: Set up uv
|
||||||
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
|
with:
|
||||||
|
version: "0.11.18"
|
||||||
|
- name: Prepare database
|
||||||
|
run: uv run bash scripts/prestart.sh
|
||||||
|
working-directory: backend
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
PROJECT_NAME: ${{ vars.PROJECT_NAME }}
|
||||||
|
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||||
|
FIRST_SUPERUSER: ${{ vars.FIRST_SUPERUSER }}
|
||||||
|
FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
|
||||||
|
- name: Deploy to FastAPI Cloud
|
||||||
|
run: uv run fastapi deploy
|
||||||
|
env:
|
||||||
|
FASTAPI_CLOUD_TOKEN: ${{ secrets.FASTAPI_CLOUD_TOKEN }}
|
||||||
|
FASTAPI_CLOUD_APP_ID: ${{ secrets.FASTAPI_CLOUD_APP_ID }}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
name: Labels
|
|
||||||
on:
|
|
||||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
# For label-checker
|
|
||||||
- labeled
|
|
||||||
- unlabeled
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
labeler:
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
steps:
|
|
||||||
- uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
|
|
||||||
if: ${{ github.event.action != 'labeled' && github.event.action != 'unlabeled' }}
|
|
||||||
- run: echo "Done adding labels"
|
|
||||||
# Run this after labeler applied labels
|
|
||||||
check-labels:
|
|
||||||
needs:
|
|
||||||
- labeler
|
|
||||||
permissions:
|
|
||||||
pull-requests: read
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
steps:
|
|
||||||
- uses: agilepathway/label-checker@c3d16ad512e7cea5961df85ff2486bb774caf3c5 # v1.6.65
|
|
||||||
with:
|
|
||||||
one_of: breaking,security,feature,bug,refactor,upgrade,docs,lang-all,internal
|
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
name: Latest Changes
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
types:
|
|
||||||
- closed
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
number:
|
|
||||||
description: PR number
|
|
||||||
required: true
|
|
||||||
debug_enabled:
|
|
||||||
description: "Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)"
|
|
||||||
required: false
|
|
||||||
default: "false"
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
latest-changes:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
permissions:
|
|
||||||
pull-requests: read
|
|
||||||
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true
|
|
||||||
steps:
|
|
||||||
- name: Dump GitHub context
|
|
||||||
env:
|
|
||||||
GITHUB_CONTEXT: ${{ toJson(github) }}
|
|
||||||
run: echo "$GITHUB_CONTEXT"
|
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.repository.default_branch }}
|
|
||||||
# To allow latest-changes to commit to the default branch
|
|
||||||
token: ${{ secrets.LATEST_CHANGES }} # zizmor: ignore[secrets-outside-env]
|
|
||||||
persist-credentials: true # required by tiangolo/latest-changes
|
|
||||||
- uses: tiangolo/latest-changes@c9b73efbc8992ef1a401e4235ea307a8ca8a724b # 0.6.1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
latest_changes_file: ./release-notes.md
|
|
||||||
latest_changes_header: "## Latest Changes"
|
|
||||||
end_regex: "^## "
|
|
||||||
debug_logs: true
|
|
||||||
label_header_prefix: "### "
|
|
||||||
@@ -12,7 +12,8 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: 'false'
|
default: 'false'
|
||||||
|
|
||||||
permissions: {}
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
changes:
|
changes:
|
||||||
@@ -22,8 +23,9 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
changed: ${{ steps.filter.outputs.changed }}
|
changed: ${{ steps.filter.outputs.changed }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# For pull requests it's not necessary to checkout the code but for the main branch it is
|
# For pull requests it's not necessary to checkout the code but for the main branch it is
|
||||||
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||||
@@ -49,13 +51,13 @@ jobs:
|
|||||||
shardTotal: [4]
|
shardTotal: [4]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
bun-version: 1.3.12
|
bun-version: 1.3.12
|
||||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version-file: .python-version
|
python-version-file: .python-version
|
||||||
- name: Setup tmate session
|
- name: Setup tmate session
|
||||||
@@ -64,7 +66,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
limit-access-to-actor: true
|
limit-access-to-actor: true
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
with:
|
with:
|
||||||
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
||||||
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
||||||
@@ -76,6 +78,7 @@ jobs:
|
|||||||
- run: bash scripts/generate-client.sh
|
- run: bash scripts/generate-client.sh
|
||||||
- run: docker compose build
|
- run: docker compose build
|
||||||
- run: docker compose down -v --remove-orphans
|
- run: docker compose down -v --remove-orphans
|
||||||
|
- run: docker compose run --rm backend bash scripts/prestart.sh
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: docker compose run --rm playwright bunx playwright test --fail-on-flaky-tests --trace=retain-on-failure --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
run: docker compose run --rm playwright bunx playwright test --fail-on-flaky-tests --trace=retain-on-failure --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||||
- run: docker compose down -v --remove-orphans
|
- run: docker compose down -v --remove-orphans
|
||||||
@@ -97,7 +100,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
|
|||||||
@@ -5,34 +5,34 @@ on:
|
|||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# Forks and Dependabot don't have access to secrets
|
CAN_PUSH: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' }}
|
||||||
HAS_SECRETS: ${{ secrets.PRE_COMMIT != '' }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pre-commit:
|
pre-commit:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- name: Dump GitHub context
|
- name: Dump GitHub context
|
||||||
env:
|
env:
|
||||||
GITHUB_CONTEXT: ${{ toJson(github) }}
|
GITHUB_CONTEXT: ${{ toJson(github) }}
|
||||||
run: echo "$GITHUB_CONTEXT"
|
run: echo "$GITHUB_CONTEXT"
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
name: Checkout PR for own repo
|
name: Checkout PR for own repo
|
||||||
if: env.HAS_SECRETS == 'true'
|
if: env.CAN_PUSH == 'true'
|
||||||
with:
|
with:
|
||||||
# To be able to commit it needs to fetch the head of the branch, not the
|
# To be able to commit it needs to fetch the head of the branch, not the
|
||||||
# merge commit
|
# merge commit
|
||||||
ref: ${{ github.head_ref }}
|
ref: ${{ github.head_ref }}
|
||||||
# And it needs the full history to be able to compute diffs
|
# And it needs the full history to be able to compute diffs
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
# A token other than the default GITHUB_TOKEN is needed to be able to trigger CI
|
persist-credentials: false
|
||||||
token: ${{ secrets.PRE_COMMIT }} # zizmor: ignore[secrets-outside-env]
|
|
||||||
persist-credentials: true # Required for `git push` command
|
|
||||||
# pre-commit lite ci needs the default checkout configs to work
|
# pre-commit lite ci needs the default checkout configs to work
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
name: Checkout PR for fork
|
name: Checkout PR for fork
|
||||||
if: env.HAS_SECRETS == 'false'
|
if: env.CAN_PUSH == 'false'
|
||||||
with:
|
with:
|
||||||
# To be able to commit it needs the head branch of the PR, the remote one
|
# To be able to commit it needs the head branch of the PR, the remote one
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
@@ -42,11 +42,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
bun-version: 1.3.12
|
bun-version: 1.3.12
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version-file: .python-version
|
python-version-file: .python-version
|
||||||
- name: Setup uv
|
- name: Setup uv
|
||||||
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
with:
|
with:
|
||||||
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
||||||
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
||||||
@@ -63,11 +63,26 @@ jobs:
|
|||||||
id: precommit
|
id: precommit
|
||||||
run: uv run prek run --from-ref origin/${GITHUB_BASE_REF} --to-ref HEAD --show-diff-on-failure
|
run: uv run prek run --from-ref origin/${GITHUB_BASE_REF} --to-ref HEAD --show-diff-on-failure
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
- name: Commit and push changes
|
- name: Check for changes
|
||||||
if: env.HAS_SECRETS == 'true'
|
id: changes
|
||||||
run: |
|
run: |
|
||||||
git config user.name "github-actions[bot]"
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
- name: Get PR Push token
|
||||||
|
id: pr-push
|
||||||
|
if: env.CAN_PUSH == 'true' && steps.changes.outputs.changed == 'true'
|
||||||
|
uses: tiangolo/pr-push@ff4e51a433de4c22bbf90597e069e8247b9203d2 # 0.0.1
|
||||||
|
- name: Commit and push changes
|
||||||
|
if: env.CAN_PUSH == 'true' && steps.changes.outputs.changed == 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ steps.pr-push.outputs.token }}
|
||||||
|
run: |
|
||||||
|
git config user.name "pr-push[bot]"
|
||||||
|
git config user.email "pr-push[bot]@users.noreply.github.com"
|
||||||
|
gh auth setup-git
|
||||||
git add -A
|
git add -A
|
||||||
if git diff --staged --quiet; then
|
if git diff --staged --quiet; then
|
||||||
echo "No changes to commit"
|
echo "No changes to commit"
|
||||||
@@ -76,7 +91,7 @@ jobs:
|
|||||||
git push
|
git push
|
||||||
fi
|
fi
|
||||||
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
|
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
|
||||||
if: env.HAS_SECRETS == 'false'
|
if: env.CAN_PUSH == 'false'
|
||||||
with:
|
with:
|
||||||
msg: 🎨 Auto format and update with pre-commit
|
msg: 🎨 Auto format and update with pre-commit
|
||||||
- name: Error out on pre-commit errors
|
- name: Error out on pre-commit errors
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
name: Prepare Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
bump:
|
||||||
|
description: Release bump
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- patch
|
||||||
|
- minor
|
||||||
|
- major
|
||||||
|
date:
|
||||||
|
description: Release date in YYYY-MM-DD format. Defaults to today.
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- name: Dump GitHub context
|
||||||
|
env:
|
||||||
|
GITHUB_CONTEXT: ${{ toJson(github) }}
|
||||||
|
run: echo "$GITHUB_CONTEXT"
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version-file: ".python-version"
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
|
with:
|
||||||
|
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
||||||
|
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
||||||
|
version: "0.11.18"
|
||||||
|
- name: Prepare release
|
||||||
|
env:
|
||||||
|
BUMP: ${{ inputs.bump }}
|
||||||
|
RELEASE_DATE: ${{ inputs.date }}
|
||||||
|
run: uv run python scripts/prepare_release.py prepare "$BUMP" --date "$RELEASE_DATE"
|
||||||
|
- name: Get release version
|
||||||
|
id: release-version
|
||||||
|
run: |
|
||||||
|
version="$(uv run python scripts/prepare_release.py current-version)"
|
||||||
|
echo "$version"
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Get PR Submit token
|
||||||
|
id: pr-submit
|
||||||
|
uses: tiangolo/pr-submit@d802fdf59bde80bc3eb8bd3259f4cbeec63de4aa # 0.0.1
|
||||||
|
- name: Create release pull request
|
||||||
|
env:
|
||||||
|
BASE_BRANCH: ${{ github.event.repository.default_branch }}
|
||||||
|
GH_TOKEN: ${{ steps.pr-submit.outputs.token }}
|
||||||
|
VERSION: ${{ steps.release-version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
branch="release-${VERSION}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
git config user.name "pr-submit[bot]"
|
||||||
|
git config user.email "pr-submit[bot]@users.noreply.github.com"
|
||||||
|
git switch -c "$branch"
|
||||||
|
git add release-notes.md
|
||||||
|
git commit -m "🔖 Release version ${VERSION}"
|
||||||
|
gh auth setup-git
|
||||||
|
git push --set-upstream origin "$branch"
|
||||||
|
gh label create release \
|
||||||
|
--description "Release preparation" \
|
||||||
|
--color ededed \
|
||||||
|
--force
|
||||||
|
gh pr create \
|
||||||
|
--base "$BASE_BRANCH" \
|
||||||
|
--head "$branch" \
|
||||||
|
--title "🔖 Release version ${VERSION}" \
|
||||||
|
--body "Prepare release ${VERSION}." \
|
||||||
|
--label release
|
||||||
@@ -13,17 +13,18 @@ jobs:
|
|||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
actions: read
|
||||||
|
contents: read
|
||||||
statuses: write
|
statuses: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version-file: .python-version
|
python-version-file: .python-version
|
||||||
- name: Setup uv
|
- name: Setup uv
|
||||||
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
with:
|
with:
|
||||||
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
||||||
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
pull_request:
|
pull_request:
|
||||||
permissions: {}
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-backend:
|
test-backend:
|
||||||
@@ -13,15 +14,15 @@ jobs:
|
|||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version-file: .python-version
|
python-version-file: .python-version
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
with:
|
with:
|
||||||
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
||||||
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
pull_request:
|
pull_request:
|
||||||
permissions: {}
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
@@ -14,14 +15,15 @@ jobs:
|
|||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- run: docker compose build
|
- run: docker compose build
|
||||||
- run: docker compose down -v --remove-orphans
|
- run: docker compose down -v --remove-orphans
|
||||||
- run: docker compose up -d --wait backend frontend adminer
|
- run: docker compose run --rm backend bash scripts/prestart.sh
|
||||||
|
- run: docker compose up -d --wait backend adminer
|
||||||
- name: Test backend is up
|
- name: Test backend is up
|
||||||
run: curl http://localhost:8000/api/v1/utils/health-check
|
run: curl http://localhost:8000/api/v1/utils/health-check
|
||||||
- name: Test frontend is up
|
- name: Test frontend is up
|
||||||
run: curl http://localhost:5173
|
run: curl http://localhost:8000
|
||||||
- run: docker compose down -v --remove-orphans
|
- run: docker compose down -v --remove-orphans
|
||||||
|
|||||||
@@ -15,11 +15,16 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
permissions:
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
|
uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
|
||||||
|
with:
|
||||||
|
advanced-security: ${{ github.repository_owner == 'fastapi' }}
|
||||||
|
annotations: ${{ github.repository_owner != 'fastapi' }}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
node_modules/
|
node_modules/
|
||||||
|
backend/app/frontend/
|
||||||
/test-results/
|
/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ repos:
|
|||||||
exclude: |
|
exclude: |
|
||||||
(?x)^(
|
(?x)^(
|
||||||
frontend/src/client/.*|
|
frontend/src/client/.*|
|
||||||
backend/app/email-templates/build/.*
|
backend/app/email-templates/.*
|
||||||
)$
|
)$
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
exclude: ^frontend/src/client/.*
|
exclude: ^frontend/src/client/.*
|
||||||
@@ -64,7 +64,7 @@ repos:
|
|||||||
entry: bash ./scripts/generate-client.sh
|
entry: bash ./scripts/generate-client.sh
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
language: unsupported
|
language: unsupported
|
||||||
files: ^backend/.*$|^scripts/generate-client\.sh$
|
files: ^backend/app/.*\.py$|^backend/pyproject\.toml$|^uv\.lock$|^frontend/openapi-ts\.config\.ts$|^scripts/generate-client\.sh$
|
||||||
|
|
||||||
- id: add-release-date
|
- id: add-release-date
|
||||||
language: unsupported
|
language: unsupported
|
||||||
|
|||||||
Vendored
-1
@@ -7,7 +7,6 @@
|
|||||||
"charliermarsh.ruff",
|
"charliermarsh.ruff",
|
||||||
"docker.docker",
|
"docker.docker",
|
||||||
"github.vscode-github-actions",
|
"github.vscode-github-actions",
|
||||||
"mjmlio.vscode-mjml",
|
|
||||||
"ms-playwright.playwright",
|
"ms-playwright.playwright",
|
||||||
"ms-python.python",
|
"ms-python.python",
|
||||||
"tombi-toml.tombi"
|
"tombi-toml.tombi"
|
||||||
|
|||||||
@@ -11,19 +11,21 @@
|
|||||||
- 🔍 [Pydantic](https://docs.pydantic.dev), used by FastAPI, for the data validation and settings management.
|
- 🔍 [Pydantic](https://docs.pydantic.dev), used by FastAPI, for the data validation and settings management.
|
||||||
- 💾 [PostgreSQL](https://www.postgresql.org) as the SQL database.
|
- 💾 [PostgreSQL](https://www.postgresql.org) as the SQL database.
|
||||||
- 🚀 [React](https://react.dev) for the frontend.
|
- 🚀 [React](https://react.dev) for the frontend.
|
||||||
|
- 🧩 Built into the backend application and served by FastAPI on the same domain as the API.
|
||||||
- 💃 Using TypeScript, hooks, [Vite](https://vitejs.dev), and other parts of a modern frontend stack.
|
- 💃 Using TypeScript, hooks, [Vite](https://vitejs.dev), and other parts of a modern frontend stack.
|
||||||
- 🎨 [Tailwind CSS](https://tailwindcss.com) and [shadcn/ui](https://ui.shadcn.com) for the frontend components.
|
- 🎨 [Tailwind CSS](https://tailwindcss.com) and [shadcn/ui](https://ui.shadcn.com) for the frontend components.
|
||||||
- 🤖 An automatically generated frontend client.
|
- 🤖 An automatically generated frontend client.
|
||||||
- 🧪 [Playwright](https://playwright.dev) for End-to-End testing.
|
- 🧪 [Playwright](https://playwright.dev) for End-to-End testing.
|
||||||
- 🦇 Dark mode support.
|
- 🦇 Dark mode support.
|
||||||
- 🐋 [Docker Compose](https://www.docker.com) for development and production.
|
- ☁️ [FastAPI Cloud](https://fastapicloud.com) for deployment.
|
||||||
|
- 🐋 [Docker Compose](https://www.docker.com) for local services and self-hosted deployment.
|
||||||
|
- 📞 [Traefik](https://traefik.io) as a reverse proxy with automatic HTTPS.
|
||||||
- 🔒 Secure password hashing by default.
|
- 🔒 Secure password hashing by default.
|
||||||
- 🔑 JWT (JSON Web Token) authentication.
|
- 🔑 JWT (JSON Web Token) authentication.
|
||||||
- 📫 Email based password recovery.
|
- 📫 Email based password recovery.
|
||||||
- 📬 [Mailcatcher](https://mailcatcher.me) for local email testing during development.
|
- 📬 [Mailcatcher](https://mailcatcher.me) for local email testing during development.
|
||||||
- ✅ Tests with [Pytest](https://pytest.org).
|
- ✅ Tests with [Pytest](https://pytest.org).
|
||||||
- 📞 [Traefik](https://traefik.io) as a reverse proxy / load balancer.
|
- 🚢 Deployment with FastAPI Cloud or self-hosting with Docker Compose and Traefik.
|
||||||
- 🚢 Deployment instructions using Docker Compose, including how to set up a frontend Traefik proxy to handle automatic HTTPS certificates.
|
|
||||||
- 🏭 CI (continuous integration) and CD (continuous deployment) based on GitHub Actions.
|
- 🏭 CI (continuous integration) and CD (continuous deployment) based on GitHub Actions.
|
||||||
|
|
||||||
### Dashboard Login
|
### Dashboard Login
|
||||||
@@ -122,17 +124,7 @@ git merge --continue
|
|||||||
|
|
||||||
### Configure
|
### Configure
|
||||||
|
|
||||||
You can then update configs in the `.env` files to customize your configurations.
|
You can update the local settings in the `.env` file. Before deploying, configure the environment variables and secrets described in the [FastAPI Cloud deployment guide](./deployment.md) or the [Docker Compose deployment guide](./deployment-docker-compose.md). Never deploy with the default `changethis` values.
|
||||||
|
|
||||||
Before deploying it, make sure you change at least the values for:
|
|
||||||
|
|
||||||
- `SECRET_KEY`
|
|
||||||
- `FIRST_SUPERUSER_PASSWORD`
|
|
||||||
- `POSTGRES_PASSWORD`
|
|
||||||
|
|
||||||
You can (and should) pass these as environment variables from secrets.
|
|
||||||
|
|
||||||
Read the [deployment.md](./deployment.md) docs for more details.
|
|
||||||
|
|
||||||
### Generate Secret Keys
|
### Generate Secret Keys
|
||||||
|
|
||||||
@@ -146,66 +138,6 @@ python -c "import secrets; print(secrets.token_urlsafe(32))"
|
|||||||
|
|
||||||
Copy the content and use that as password / secret key. And run that again to generate another secure key.
|
Copy the content and use that as password / secret key. And run that again to generate another secure key.
|
||||||
|
|
||||||
## How To Use It - Alternative With Copier
|
|
||||||
|
|
||||||
This repository also supports generating a new project using [Copier](https://copier.readthedocs.io).
|
|
||||||
|
|
||||||
It will copy all the files, ask you configuration questions, and update the `.env` files with your answers.
|
|
||||||
|
|
||||||
### Install Copier
|
|
||||||
|
|
||||||
You can install Copier with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install copier
|
|
||||||
```
|
|
||||||
|
|
||||||
Or better, if you have [`pipx`](https://pipx.pypa.io/), you can run it with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pipx install copier
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: If you have `pipx`, installing copier is optional, you could run it directly.
|
|
||||||
|
|
||||||
### Generate a Project With Copier
|
|
||||||
|
|
||||||
Decide a name for your new project's directory, you will use it below. For example, `my-awesome-project`.
|
|
||||||
|
|
||||||
Go to the directory that will be the parent of your project, and run the command with your project's name:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust
|
|
||||||
```
|
|
||||||
|
|
||||||
If you have `pipx` and you didn't install `copier`, you can run it directly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pipx run copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note** the `--trust` option is necessary to be able to execute a [post-creation script](https://github.com/fastapi/full-stack-fastapi-template/blob/master/.copier/update_dotenv.py) that updates your `.env` files.
|
|
||||||
|
|
||||||
### Input Variables
|
|
||||||
|
|
||||||
Copier will ask you for some data, you might want to have at hand before generating the project.
|
|
||||||
|
|
||||||
But don't worry, you can just update any of that in the `.env` files afterwards.
|
|
||||||
|
|
||||||
The input variables, with their default values (some auto generated) are:
|
|
||||||
|
|
||||||
- `project_name`: (default: `"FastAPI Project"`) The name of the project, shown to API users (in .env).
|
|
||||||
- `stack_name`: (default: `"fastapi-project"`) The name of the stack used for Docker Compose labels and project name (no spaces, no periods) (in .env).
|
|
||||||
- `secret_key`: (default: `"changethis"`) The secret key for the project, used for security, stored in .env, you can generate one with the method above.
|
|
||||||
- `first_superuser`: (default: `"admin@example.com"`) The email of the first superuser (in .env).
|
|
||||||
- `first_superuser_password`: (default: `"changethis"`) The password of the first superuser (in .env).
|
|
||||||
- `smtp_host`: (default: "") The SMTP server host to send emails, you can set it later in .env.
|
|
||||||
- `smtp_user`: (default: "") The SMTP server user to send emails, you can set it later in .env.
|
|
||||||
- `smtp_password`: (default: "") The SMTP server password to send emails, you can set it later in .env.
|
|
||||||
- `emails_from_email`: (default: `"info@example.com"`) The email account to send emails from, you can set it later in .env.
|
|
||||||
- `postgres_password`: (default: `"changethis"`) The password for the PostgreSQL database, stored in .env, you can generate one with the method above.
|
|
||||||
- `sentry_dsn`: (default: "") The DSN for Sentry, if you are using it, you can set it later in .env.
|
|
||||||
|
|
||||||
## Backend Development
|
## Backend Development
|
||||||
|
|
||||||
Backend docs: [backend/README.md](./backend/README.md).
|
Backend docs: [backend/README.md](./backend/README.md).
|
||||||
@@ -218,11 +150,13 @@ Frontend docs: [frontend/README.md](./frontend/README.md).
|
|||||||
|
|
||||||
Deployment docs: [deployment.md](./deployment.md).
|
Deployment docs: [deployment.md](./deployment.md).
|
||||||
|
|
||||||
|
Docker Compose deployment: [deployment-docker-compose.md](./deployment-docker-compose.md).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
General development docs: [development.md](./development.md).
|
General development docs: [development.md](./development.md).
|
||||||
|
|
||||||
This includes using Docker Compose, custom local domains, `.env` configurations, etc.
|
This includes the local FastAPI and Vite workflow, Docker Compose services, `.env` configuration, and more.
|
||||||
|
|
||||||
## Release Notes
|
## Release Notes
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,21 @@
|
|||||||
|
FROM oven/bun:1 AS frontend-build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json bun.lock /app/
|
||||||
|
COPY frontend/package.json /app/frontend/
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
|
RUN bun install
|
||||||
|
|
||||||
|
COPY ./frontend /app/frontend
|
||||||
|
|
||||||
|
ARG VITE_API_URL=
|
||||||
|
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.14
|
FROM python:3.14
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
@@ -33,6 +51,8 @@ COPY ./backend/pyproject.toml ./backend/alembic.ini /app/backend/
|
|||||||
|
|
||||||
COPY ./backend/app /app/backend/app
|
COPY ./backend/app /app/backend/app
|
||||||
|
|
||||||
|
COPY --from=frontend-build /app/backend/app/frontend /app/backend/app/frontend
|
||||||
|
|
||||||
# Sync the project
|
# Sync the project
|
||||||
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers
|
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
|||||||
+40
-67
@@ -5,27 +5,29 @@
|
|||||||
* [Docker](https://www.docker.com/).
|
* [Docker](https://www.docker.com/).
|
||||||
* [uv](https://docs.astral.sh/uv/) for Python package and environment management.
|
* [uv](https://docs.astral.sh/uv/) for Python package and environment management.
|
||||||
|
|
||||||
## Docker Compose
|
## Local Development
|
||||||
|
|
||||||
Start the local development environment with Docker Compose following the guide in [../development.md](../development.md).
|
Run the backend locally and connect it to PostgreSQL in Docker Compose.
|
||||||
|
|
||||||
## General Workflow
|
From the project root, start PostgreSQL and Mailcatcher:
|
||||||
|
|
||||||
By default, the dependencies are managed with [uv](https://docs.astral.sh/uv/), go there and install it.
|
```console
|
||||||
|
$ docker compose up -d db mailcatcher
|
||||||
|
```
|
||||||
|
|
||||||
From `./backend/` you can install all the dependencies with:
|
Then, from `./backend/`, install the dependencies, prepare the database, and start the development server:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ uv sync
|
$ uv sync
|
||||||
|
$ uv run bash scripts/prestart.sh
|
||||||
|
$ uv run fastapi dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Then you can activate the virtual environment with:
|
The API is available at `http://localhost:8000`, with automatic interactive docs at `http://localhost:8000/docs`.
|
||||||
|
|
||||||
```console
|
## General Workflow
|
||||||
$ source .venv/bin/activate
|
|
||||||
```
|
|
||||||
|
|
||||||
Make sure your editor is using the correct Python virtual environment, with the interpreter at `backend/.venv/bin/python`.
|
Run backend commands from `./backend/` with `uv run`. Make sure your editor uses the Python interpreter at `.venv/bin/python` in the project root.
|
||||||
|
|
||||||
Modify or add SQLModel models for data and SQL tables in `./backend/app/models.py`, API endpoints in `./backend/app/api/`, CRUD (Create, Read, Update, Delete) utils in `./backend/app/crud.py`.
|
Modify or add SQLModel models for data and SQL tables in `./backend/app/models.py`, API endpoints in `./backend/app/api/`, CRUD (Create, Read, Update, Delete) utils in `./backend/app/crud.py`.
|
||||||
|
|
||||||
@@ -35,66 +37,33 @@ There are already configurations in place to run the backend through the VS Code
|
|||||||
|
|
||||||
The setup is also already configured so you can run the tests through the VS Code Python tests tab.
|
The setup is also already configured so you can run the tests through the VS Code Python tests tab.
|
||||||
|
|
||||||
## Docker Compose Override
|
## Full Stack with Docker Compose
|
||||||
|
|
||||||
During development, you can change Docker Compose settings that will only affect the local development environment in the file `compose.override.yml`.
|
To run the backend and built frontend in Docker Compose:
|
||||||
|
|
||||||
The changes to that file only affect the local development environment, not the production environment. So, you can add "temporary" changes that help the development workflow.
|
|
||||||
|
|
||||||
For example, the directory with the backend code is synchronized in the Docker container, copying the code you change live to the directory inside the container. That allows you to test your changes right away, without having to build the Docker image again. It should only be done during development, for production, you should build the Docker image with a recent version of the backend code. But during development, it allows you to iterate very fast.
|
|
||||||
|
|
||||||
There is also a command override that runs `fastapi run --reload` instead of the default `fastapi run`. It starts a single server process (instead of multiple, as would be for production) and reloads the process whenever the code changes. Have in mind that if you have a syntax error and save the Python file, it will break and exit, and the container will stop. After that, you can restart the container by fixing the error and running again:
|
|
||||||
|
|
||||||
```console
|
```console
|
||||||
|
$ docker compose run --rm backend bash scripts/prestart.sh
|
||||||
$ docker compose watch
|
$ docker compose watch
|
||||||
```
|
```
|
||||||
|
|
||||||
There is also a commented out `command` override, you can uncomment it and comment the default one. It makes the backend container run a process that does "nothing", but keeps the container alive. That allows you to get inside your running container and execute commands inside, for example a Python interpreter to test installed dependencies, or start the development server that reloads when it detects changes.
|
The application is available at `http://localhost:8000`.
|
||||||
|
|
||||||
To get inside the container with a `bash` session you can start the stack with:
|
### Docker Compose Override
|
||||||
|
|
||||||
```console
|
The `compose.override.yml` file contains local settings for published ports, source synchronization, automatic image rebuilds, and backend reloads. Docker Compose applies it automatically when you run `docker compose` without an explicit file list.
|
||||||
$ docker compose watch
|
|
||||||
```
|
|
||||||
|
|
||||||
and then in another terminal, `exec` inside the running container:
|
To open a shell in the backend container:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ docker compose exec backend bash
|
$ docker compose exec backend bash
|
||||||
```
|
```
|
||||||
|
|
||||||
You should see an output like:
|
|
||||||
|
|
||||||
```console
|
|
||||||
root@7f2607af31c3:/app#
|
|
||||||
```
|
|
||||||
|
|
||||||
that means that you are in a `bash` session inside your container, as a `root` user, under the `/app` directory, this directory has another directory called "app" inside, that's where your code lives inside the container: `/app/app`.
|
|
||||||
|
|
||||||
There you can use the `fastapi run --reload` command to run the debug live reloading server.
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ fastapi run --reload app/main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
...it will look like:
|
|
||||||
|
|
||||||
```console
|
|
||||||
root@7f2607af31c3:/app# fastapi run --reload app/main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
and then hit enter. That runs the live reloading server that auto reloads when it detects code changes.
|
|
||||||
|
|
||||||
Nevertheless, if it doesn't detect a change but a syntax error, it will just stop with an error. But as the container is still alive and you are in a Bash session, you can quickly restart it after fixing the error, running the same command ("up arrow" and "Enter").
|
|
||||||
|
|
||||||
...this previous detail is what makes it useful to have the container alive doing nothing and then, in a Bash session, make it run the live reload server.
|
|
||||||
|
|
||||||
## Backend tests
|
## Backend tests
|
||||||
|
|
||||||
To test the backend run:
|
To test the backend from the `backend` directory, run:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ bash ./scripts/test.sh
|
$ uv run bash ./scripts/test.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The tests run with Pytest, modify and add tests to `./backend/tests/`.
|
The tests run with Pytest, modify and add tests to `./backend/tests/`.
|
||||||
@@ -123,22 +92,14 @@ When the tests are run, a file `htmlcov/index.html` is generated, you can open i
|
|||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
As during local development your app directory is mounted as a volume inside the container, you can also run the migrations with `alembic` commands inside the container and the migration code will be in your app directory (instead of being only inside the container). So you can add it to your git repository.
|
Make sure you create a revision of your models and upgrade the database with that revision every time you change them. From the `backend` directory, use `uv` to run Alembic against the PostgreSQL container:
|
||||||
|
|
||||||
Make sure you create a "revision" of your models and that you "upgrade" your database with that revision every time you change them. As this is what will update the tables in your database. Otherwise, your application will have errors.
|
|
||||||
|
|
||||||
* Start an interactive session in the backend container:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ docker compose exec backend bash
|
|
||||||
```
|
|
||||||
|
|
||||||
* Alembic is already configured to import your SQLModel models from `./backend/app/models.py`.
|
* Alembic is already configured to import your SQLModel models from `./backend/app/models.py`.
|
||||||
|
|
||||||
* After changing a model (for example, adding a column), inside the container, create a revision, e.g.:
|
* After changing a model (for example, adding a column), create a revision:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ alembic revision --autogenerate -m "Add column last_name to User model"
|
$ uv run alembic revision --autogenerate -m "Add column last_name to User model"
|
||||||
```
|
```
|
||||||
|
|
||||||
* Commit to the git repository the files generated in the alembic directory.
|
* Commit to the git repository the files generated in the alembic directory.
|
||||||
@@ -146,7 +107,7 @@ $ alembic revision --autogenerate -m "Add column last_name to User model"
|
|||||||
* After creating the revision, run the migration in the database (this is what will actually change the database):
|
* After creating the revision, run the migration in the database (this is what will actually change the database):
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ alembic upgrade head
|
$ uv run alembic upgrade head
|
||||||
```
|
```
|
||||||
|
|
||||||
If you don't want to use migrations at all, uncomment the lines in the file at `./backend/app/core/db.py` that end in:
|
If you don't want to use migrations at all, uncomment the lines in the file at `./backend/app/core/db.py` that end in:
|
||||||
@@ -165,8 +126,20 @@ If you don't want to start with the default models and want to remove them / mod
|
|||||||
|
|
||||||
## Email Templates
|
## Email Templates
|
||||||
|
|
||||||
The email templates are in `./backend/app/email-templates/`. Here, there are two directories: `build` and `src`. The `src` directory contains the source files that are used to build the final email templates. The `build` directory contains the final email templates that are used by the application.
|
The email templates are written with [React Email](https://react.email) in `./packages/react-email/`. The `emails` directory holds one component per email and the `ui` directory holds the shared components (layout, heading, button, link, callout).
|
||||||
|
|
||||||
Before continuing, ensure you have the [MJML extension](https://github.com/mjmlio/vscode-mjml) installed in your VS Code.
|
The rendered HTML in `./backend/app/email-templates/` is generated from those components, it is what the application sends, and it shouldn't be edited by hand.
|
||||||
|
|
||||||
Once you have the MJML extension installed, you can create a new email template in the `src` directory. After creating the new email template and with the `.mjml` file open in your editor, open the command palette with `Ctrl+Shift+P` and search for `MJML: Export to HTML`. This will convert the `.mjml` file to a `.html` file and now you can save it in the build directory.
|
To preview the emails while editing them, start the dev server from the root of the project:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ bun run email:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Values coming from the backend are declared as Jinja placeholders in the component props, for example `username = "{{ username }}"`. The context for each email is built in `generate_*_email()` in `./backend/app/utils.py`, so a new placeholder needs to be added there too.
|
||||||
|
|
||||||
|
Once you are done, regenerate the templates used by the application:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ bun run email:export
|
||||||
|
```
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ target_metadata = SQLModel.metadata
|
|||||||
|
|
||||||
|
|
||||||
def get_url():
|
def get_url():
|
||||||
return str(settings.SQLALCHEMY_DATABASE_URI)
|
return str(settings.DATABASE_URL)
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline():
|
def run_migrations_offline():
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ api_router.include_router(utils.router)
|
|||||||
api_router.include_router(items.router)
|
api_router.include_router(items.router)
|
||||||
|
|
||||||
|
|
||||||
if settings.ENVIRONMENT == "local":
|
if settings.FASTAPI_ENV == "development":
|
||||||
api_router.include_router(private.router)
|
api_router.include_router(private.router)
|
||||||
|
|||||||
+16
-43
@@ -1,27 +1,17 @@
|
|||||||
import secrets
|
|
||||||
import warnings
|
import warnings
|
||||||
from typing import Annotated, Any, Literal, Self
|
from typing import Literal, Self
|
||||||
|
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
AnyUrl,
|
|
||||||
BeforeValidator,
|
|
||||||
EmailStr,
|
EmailStr,
|
||||||
HttpUrl,
|
HttpUrl,
|
||||||
PostgresDsn,
|
PostgresDsn,
|
||||||
computed_field,
|
computed_field,
|
||||||
|
field_validator,
|
||||||
model_validator,
|
model_validator,
|
||||||
)
|
)
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
def parse_cors(v: Any) -> list[str] | str:
|
|
||||||
if isinstance(v, str) and not v.startswith("["):
|
|
||||||
return [i.strip() for i in v.split(",") if i.strip()]
|
|
||||||
elif isinstance(v, list | str):
|
|
||||||
return v
|
|
||||||
raise ValueError(v)
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
# Use top level .env file (one level above ./backend/)
|
# Use top level .env file (one level above ./backend/)
|
||||||
@@ -30,42 +20,24 @@ class Settings(BaseSettings):
|
|||||||
extra="ignore",
|
extra="ignore",
|
||||||
)
|
)
|
||||||
API_V1_STR: str = "/api/v1"
|
API_V1_STR: str = "/api/v1"
|
||||||
SECRET_KEY: str = secrets.token_urlsafe(32)
|
SECRET_KEY: str
|
||||||
# 60 minutes * 24 hours * 8 days = 8 days
|
# 60 minutes * 24 hours * 8 days = 8 days
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
|
||||||
FRONTEND_HOST: str = "http://localhost:5173"
|
FRONTEND_HOST: str = "http://localhost:5173"
|
||||||
ENVIRONMENT: Literal["local", "staging", "production"] = "local"
|
FASTAPI_ENV: Literal["development"] | None = None
|
||||||
|
|
||||||
BACKEND_CORS_ORIGINS: Annotated[
|
|
||||||
list[AnyUrl] | str, BeforeValidator(parse_cors)
|
|
||||||
] = []
|
|
||||||
|
|
||||||
@computed_field # type: ignore[prop-decorator]
|
|
||||||
@property
|
|
||||||
def all_cors_origins(self) -> list[str]:
|
|
||||||
return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [
|
|
||||||
self.FRONTEND_HOST
|
|
||||||
]
|
|
||||||
|
|
||||||
PROJECT_NAME: str
|
PROJECT_NAME: str
|
||||||
SENTRY_DSN: HttpUrl | None = None
|
SENTRY_DSN: HttpUrl | None = None
|
||||||
POSTGRES_SERVER: str
|
DATABASE_URL: PostgresDsn
|
||||||
POSTGRES_PORT: int = 5432
|
|
||||||
POSTGRES_USER: str
|
|
||||||
POSTGRES_PASSWORD: str = ""
|
|
||||||
POSTGRES_DB: str = ""
|
|
||||||
|
|
||||||
@computed_field # type: ignore[prop-decorator]
|
@field_validator("DATABASE_URL", mode="before")
|
||||||
@property
|
@classmethod
|
||||||
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
|
def _use_psycopg_driver(cls, value: str | PostgresDsn) -> str:
|
||||||
return PostgresDsn.build(
|
database_url = str(value)
|
||||||
scheme="postgresql+psycopg",
|
for scheme in ("postgres://", "postgresql://"):
|
||||||
username=self.POSTGRES_USER,
|
if database_url.startswith(scheme):
|
||||||
password=self.POSTGRES_PASSWORD,
|
return database_url.replace(scheme, "postgresql+psycopg://", 1)
|
||||||
host=self.POSTGRES_SERVER,
|
return database_url
|
||||||
port=self.POSTGRES_PORT,
|
|
||||||
path=self.POSTGRES_DB,
|
|
||||||
)
|
|
||||||
|
|
||||||
SMTP_TLS: bool = True
|
SMTP_TLS: bool = True
|
||||||
SMTP_SSL: bool = False
|
SMTP_SSL: bool = False
|
||||||
@@ -99,7 +71,7 @@ class Settings(BaseSettings):
|
|||||||
f'The value of {var_name} is "changethis", '
|
f'The value of {var_name} is "changethis", '
|
||||||
"for security, please change it, at least for deployments."
|
"for security, please change it, at least for deployments."
|
||||||
)
|
)
|
||||||
if self.ENVIRONMENT == "local":
|
if self.FASTAPI_ENV == "development":
|
||||||
warnings.warn(message, stacklevel=1)
|
warnings.warn(message, stacklevel=1)
|
||||||
else:
|
else:
|
||||||
raise ValueError(message)
|
raise ValueError(message)
|
||||||
@@ -107,7 +79,8 @@ class Settings(BaseSettings):
|
|||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _enforce_non_default_secrets(self) -> Self:
|
def _enforce_non_default_secrets(self) -> Self:
|
||||||
self._check_default_secret("SECRET_KEY", self.SECRET_KEY)
|
self._check_default_secret("SECRET_KEY", self.SECRET_KEY)
|
||||||
self._check_default_secret("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD)
|
for host in self.DATABASE_URL.hosts():
|
||||||
|
self._check_default_secret("DATABASE_URL password", host["password"])
|
||||||
self._check_default_secret(
|
self._check_default_secret(
|
||||||
"FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD
|
"FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from app import crud
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models import User, UserCreate
|
from app.models import User, UserCreate
|
||||||
|
|
||||||
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
|
engine = create_engine(str(settings.DATABASE_URL))
|
||||||
|
|
||||||
|
|
||||||
# make sure all SQLModel models are imported (app.models) before initializing DB
|
# make sure all SQLModel models are imported (app.models) before initializing DB
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
<!doctype html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><head><title></title><!--[if !mso]><!-- --><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]--><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style type="text/css">#outlook a { padding:0; }
|
|
||||||
.ReadMsgBody { width:100%; }
|
|
||||||
.ExternalClass { width:100%; }
|
|
||||||
.ExternalClass * { line-height:100%; }
|
|
||||||
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
|
|
||||||
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
|
|
||||||
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
|
|
||||||
p { display:block;margin:13px 0; }</style><!--[if !mso]><!--><style type="text/css">@media only screen and (max-width:480px) {
|
|
||||||
@-ms-viewport { width:320px; }
|
|
||||||
@viewport { width:320px; }
|
|
||||||
}</style><!--<![endif]--><!--[if mso]>
|
|
||||||
<xml>
|
|
||||||
<o:OfficeDocumentSettings>
|
|
||||||
<o:AllowPNG/>
|
|
||||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
||||||
</o:OfficeDocumentSettings>
|
|
||||||
</xml>
|
|
||||||
<![endif]--><!--[if lte mso 11]>
|
|
||||||
<style type="text/css">
|
|
||||||
.outlook-group-fix { width:100% !important; }
|
|
||||||
</style>
|
|
||||||
<![endif]--><!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css"><style type="text/css">@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);</style><!--<![endif]--><style type="text/css">@media only screen and (min-width:480px) {
|
|
||||||
.mj-column-per-100 { width:100% !important; max-width: 100%; }
|
|
||||||
}</style><style type="text/css"></style></head><body style="background-color:#fafbfc;"><div style="background-color:#fafbfc;"><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--><div style="background:#ffffff;background-color:#ffffff;Margin:0px auto;max-width:600px;"><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"><tbody><tr><td style="direction:ltr;font-size:0px;padding:40px 20px;text-align:center;vertical-align:top;"><!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:middle;width:560px;" ><![endif]--><div class="mj-column-per-100 outlook-group-fix" style="font-size:13px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%"><tr><td align="center" style="font-size:0px;padding:35px;word-break:break-word;"><div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:20px;line-height:1;text-align:center;color:#333333;">{{ project_name }} - New Account</div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;"><span>Welcome to your new account!</span></div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;">Here are your account details:</div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;">Username: {{ username }}</div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;">Password: {{ password }}</div></td></tr><tr><td align="center" vertical-align="middle" style="font-size:0px;padding:15px 30px;word-break:break-word;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"><tr><td align="center" bgcolor="#009688" role="presentation" style="border:none;border-radius:8px;cursor:auto;padding:10px 25px;background:#009688;" valign="middle"><a href="{{ link }}" style="background:#009688;color:#ffffff;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:18px;font-weight:normal;line-height:120%;Margin:0;text-decoration:none;text-transform:none;" target="_blank">Go to Dashboard</a></td></tr></table></td></tr><tr><td style="font-size:0px;padding:10px 25px;word-break:break-word;"><p style="border-top:solid 2px #cccccc;font-size:1;margin:0px auto;width:100%;"></p><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #cccccc;font-size:1;margin:0px auto;width:510px;" role="presentation" width="510px" ><tr><td style="height:0;line-height:0;">
|
|
||||||
</td></tr></table><![endif]--></td></tr></table></div><!--[if mso | IE]></td></tr></table><![endif]--></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><![endif]--></div></body></html>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<!doctype html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><head><title></title><!--[if !mso]><!-- --><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]--><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style type="text/css">#outlook a { padding:0; }
|
|
||||||
.ReadMsgBody { width:100%; }
|
|
||||||
.ExternalClass { width:100%; }
|
|
||||||
.ExternalClass * { line-height:100%; }
|
|
||||||
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
|
|
||||||
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
|
|
||||||
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
|
|
||||||
p { display:block;margin:13px 0; }</style><!--[if !mso]><!--><style type="text/css">@media only screen and (max-width:480px) {
|
|
||||||
@-ms-viewport { width:320px; }
|
|
||||||
@viewport { width:320px; }
|
|
||||||
}</style><!--<![endif]--><!--[if mso]>
|
|
||||||
<xml>
|
|
||||||
<o:OfficeDocumentSettings>
|
|
||||||
<o:AllowPNG/>
|
|
||||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
||||||
</o:OfficeDocumentSettings>
|
|
||||||
</xml>
|
|
||||||
<![endif]--><!--[if lte mso 11]>
|
|
||||||
<style type="text/css">
|
|
||||||
.outlook-group-fix { width:100% !important; }
|
|
||||||
</style>
|
|
||||||
<![endif]--><!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css"><style type="text/css">@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);</style><!--<![endif]--><style type="text/css">@media only screen and (min-width:480px) {
|
|
||||||
.mj-column-per-100 { width:100% !important; max-width: 100%; }
|
|
||||||
}</style><style type="text/css"></style></head><body style="background-color:#fafbfc;"><div style="background-color:#fafbfc;"><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--><div style="background:#ffffff;background-color:#ffffff;Margin:0px auto;max-width:600px;"><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"><tbody><tr><td style="direction:ltr;font-size:0px;padding:40px 20px;text-align:center;vertical-align:top;"><!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:middle;width:560px;" ><![endif]--><div class="mj-column-per-100 outlook-group-fix" style="font-size:13px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%"><tr><td align="center" style="font-size:0px;padding:35px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:20px;line-height:1;text-align:center;color:#333333;">{{ project_name }} - Password Recovery</div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;"><span>Hello {{ username }}</span></div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;">We've received a request to reset your password. You can do it by clicking the button below:</div></td></tr><tr><td align="center" vertical-align="middle" style="font-size:0px;padding:15px 30px;word-break:break-word;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"><tr><td align="center" bgcolor="#009688" role="presentation" style="border:none;border-radius:8px;cursor:auto;padding:10px 25px;background:#009688;" valign="middle"><a href="{{ link }}" style="background:#009688;color:#ffffff;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:18px;font-weight:normal;line-height:120%;Margin:0;text-decoration:none;text-transform:none;" target="_blank">Reset password</a></td></tr></table></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;">Or copy and paste the following link into your browser:</div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;"><a href="{{ link }}">{{ link }}</a></div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;">This password will expire in {{ valid_hours }} hours.</div></td></tr><tr><td style="font-size:0px;padding:10px 25px;word-break:break-word;"><p style="border-top:solid 2px #cccccc;font-size:1;margin:0px auto;width:100%;"></p><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #cccccc;font-size:1;margin:0px auto;width:510px;" role="presentation" width="510px" ><tr><td style="height:0;line-height:0;">
|
|
||||||
</td></tr></table><![endif]--></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:14px;line-height:1;text-align:center;color:#555555;">If you didn't request a password recovery you can disregard this email.</div></td></tr></table></div><!--[if mso | IE]></td></tr></table><![endif]--></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><![endif]--></div></body></html>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<!doctype html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><head><title></title><!--[if !mso]><!-- --><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]--><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style type="text/css">#outlook a { padding:0; }
|
|
||||||
.ReadMsgBody { width:100%; }
|
|
||||||
.ExternalClass { width:100%; }
|
|
||||||
.ExternalClass * { line-height:100%; }
|
|
||||||
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
|
|
||||||
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
|
|
||||||
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
|
|
||||||
p { display:block;margin:13px 0; }</style><!--[if !mso]><!--><style type="text/css">@media only screen and (max-width:480px) {
|
|
||||||
@-ms-viewport { width:320px; }
|
|
||||||
@viewport { width:320px; }
|
|
||||||
}</style><!--<![endif]--><!--[if mso]>
|
|
||||||
<xml>
|
|
||||||
<o:OfficeDocumentSettings>
|
|
||||||
<o:AllowPNG/>
|
|
||||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
||||||
</o:OfficeDocumentSettings>
|
|
||||||
</xml>
|
|
||||||
<![endif]--><!--[if lte mso 11]>
|
|
||||||
<style type="text/css">
|
|
||||||
.outlook-group-fix { width:100% !important; }
|
|
||||||
</style>
|
|
||||||
<![endif]--><style type="text/css">@media only screen and (min-width:480px) {
|
|
||||||
.mj-column-per-100 { width:100% !important; max-width: 100%; }
|
|
||||||
}</style><style type="text/css"></style></head><body style="background-color:#fafbfc;"><div style="background-color:#fafbfc;"><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--><div style="background:#ffffff;background-color:#ffffff;Margin:0px auto;max-width:600px;"><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"><tbody><tr><td style="direction:ltr;font-size:0px;padding:40px 20px;text-align:center;vertical-align:top;"><!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:middle;width:560px;" ><![endif]--><div class="mj-column-per-100 outlook-group-fix" style="font-size:13px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%"><tr><td align="center" style="font-size:0px;padding:35px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:20px;line-height:1;text-align:center;color:#333333;">{{ project_name }}</div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;"><span>Test email for: {{ email }}</span></div></td></tr><tr><td style="font-size:0px;padding:10px 25px;word-break:break-word;"><p style="border-top:solid 2px #cccccc;font-size:1;margin:0px auto;width:100%;"></p><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #cccccc;font-size:1;margin:0px auto;width:510px;" role="presentation" width="510px" ><tr><td style="height:0;line-height:0;">
|
|
||||||
</td></tr></table><![endif]--></td></tr></table></div><!--[if mso | IE]></td></tr></table><![endif]--></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><![endif]--></div></body></html>
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,15 +0,0 @@
|
|||||||
<mjml>
|
|
||||||
<mj-body background-color="#fafbfc">
|
|
||||||
<mj-section background-color="#fff" padding="40px 20px">
|
|
||||||
<mj-column vertical-align="middle" width="100%">
|
|
||||||
<mj-text align="center" padding="35px" font-size="20px" color="#333">{{ project_name }} - New Account</mj-text>
|
|
||||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555"><span>Welcome to your new account!</span></mj-text>
|
|
||||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">Here are your account details:</mj-text>
|
|
||||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">Username: {{ username }}</mj-text>
|
|
||||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">Password: {{ password }}</mj-text>
|
|
||||||
<mj-button align="center" font-size="18px" background-color="#009688" border-radius="8px" color="#fff" href="{{ link }}" padding="15px 30px">Go to Dashboard</mj-button>
|
|
||||||
<mj-divider border-color="#ccc" border-width="2px"></mj-divider>
|
|
||||||
</mj-column>
|
|
||||||
</mj-section>
|
|
||||||
</mj-body>
|
|
||||||
</mjml>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<mjml>
|
|
||||||
<mj-body background-color="#fafbfc">
|
|
||||||
<mj-section background-color="#fff" padding="40px 20px">
|
|
||||||
<mj-column vertical-align="middle" width="100%">
|
|
||||||
<mj-text align="center" padding="35px" font-size="20px" font-family="Arial, Helvetica, sans-serif" color="#333">{{ project_name }} - Password Recovery</mj-text>
|
|
||||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555"><span>Hello {{ username }}</span></mj-text>
|
|
||||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">We've received a request to reset your password. You can do it by clicking the button below:</mj-text>
|
|
||||||
<mj-button align="center" font-size="18px" background-color="#009688" border-radius="8px" color="#fff" href="{{ link }}" padding="15px 30px">Reset password</mj-button>
|
|
||||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">Or copy and paste the following link into your browser:</mj-text>
|
|
||||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555"><a href="{{ link }}">{{ link }}</a></mj-text>
|
|
||||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">This password will expire in {{ valid_hours }} hours.</mj-text>
|
|
||||||
<mj-divider border-color="#ccc" border-width="2px"></mj-divider>
|
|
||||||
<mj-text align="center" font-size="14px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">If you didn't request a password recovery you can disregard this email.</mj-text>
|
|
||||||
</mj-column>
|
|
||||||
</mj-section>
|
|
||||||
</mj-body>
|
|
||||||
</mjml>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<mjml>
|
|
||||||
<mj-body background-color="#fafbfc">
|
|
||||||
<mj-section background-color="#fff" padding="40px 20px">
|
|
||||||
<mj-column vertical-align="middle" width="100%">
|
|
||||||
<mj-text align="center" padding="35px" font-size="20px" font-family="Arial, Helvetica, sans-serif" color="#333">{{ project_name }}</mj-text>
|
|
||||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family=", sans-serif" color="#555"><span>Test email for: {{ email }}</span></mj-text>
|
|
||||||
<mj-divider border-color="#ccc" border-width="2px"></mj-divider>
|
|
||||||
</mj-column>
|
|
||||||
</mj-section>
|
|
||||||
</mj-body>
|
|
||||||
</mjml>
|
|
||||||
File diff suppressed because one or more lines are too long
+9
-6
@@ -1,3 +1,5 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.routing import APIRoute
|
from fastapi.routing import APIRoute
|
||||||
@@ -6,12 +8,14 @@ from starlette.middleware.cors import CORSMiddleware
|
|||||||
from app.api.main import api_router
|
from app.api.main import api_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
|
FRONTEND_DIR = Path(__file__).parent / "frontend"
|
||||||
|
|
||||||
|
|
||||||
def custom_generate_unique_id(route: APIRoute) -> str:
|
def custom_generate_unique_id(route: APIRoute) -> str:
|
||||||
return f"{route.tags[0]}-{route.name}"
|
return f"{route.tags[0]}-{route.name}"
|
||||||
|
|
||||||
|
|
||||||
if settings.SENTRY_DSN and settings.ENVIRONMENT != "local":
|
if settings.SENTRY_DSN and settings.FASTAPI_ENV != "development":
|
||||||
sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True)
|
sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True)
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -20,14 +24,13 @@ app = FastAPI(
|
|||||||
generate_unique_id_function=custom_generate_unique_id,
|
generate_unique_id_function=custom_generate_unique_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set all CORS enabled origins
|
app.add_middleware(
|
||||||
if settings.all_cors_origins:
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.all_cors_origins,
|
allow_origins=[settings.FRONTEND_HOST],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(api_router, prefix=settings.API_V1_STR)
|
app.include_router(api_router, prefix=settings.API_V1_STR)
|
||||||
|
app.frontend("/", directory=FRONTEND_DIR)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class EmailData:
|
|||||||
|
|
||||||
def render_email_template(*, template_name: str, context: dict[str, Any]) -> str:
|
def render_email_template(*, template_name: str, context: dict[str, Any]) -> str:
|
||||||
template_str = (
|
template_str = (
|
||||||
Path(__file__).parent / "email-templates" / "build" / template_name
|
Path(__file__).parent / "email-templates" / template_name
|
||||||
).read_text()
|
).read_text()
|
||||||
html_content = Template(template_str).render(context)
|
html_content = Template(template_str).render(context)
|
||||||
return html_content
|
return html_content
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
description = ""
|
description = ""
|
||||||
requires-python = ">=3.14,<4.0"
|
requires-python = ">=3.14,<4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi[standard]>=0.139.0,<1.0.0",
|
"fastapi[standard]>=0.141.1,<1.0.0",
|
||||||
"python-multipart<1.0.0,>=0.0.27",
|
"python-multipart<1.0.0,>=0.0.27",
|
||||||
"email-validator<3.0.0.0,>=2.1.0.post1",
|
"email-validator<3.0.0.0,>=2.1.0.post1",
|
||||||
"tenacity<10.0.0,>=8.2.3",
|
"tenacity<10.0.0,>=8.2.3",
|
||||||
@@ -13,10 +13,10 @@ dependencies = [
|
|||||||
"jinja2<4.0.0,>=3.1.4",
|
"jinja2<4.0.0,>=3.1.4",
|
||||||
"alembic<2.0.0,>=1.12.1",
|
"alembic<2.0.0,>=1.12.1",
|
||||||
"httpx<1.0.0,>=0.25.1",
|
"httpx<1.0.0,>=0.25.1",
|
||||||
"psycopg[binary]<4.0.0,>=3.1.13",
|
"psycopg[binary]>=3.3.4,<4.0.0",
|
||||||
"sqlmodel>=0.0.39,<1.0.0",
|
"sqlmodel>=0.0.39,<1.0.0",
|
||||||
"pydantic-settings<3.0.0,>=2.2.1",
|
"pydantic-settings<3.0.0,>=2.2.1",
|
||||||
"sentry-sdk[fastapi]>=2.63.0,<3.0.0",
|
"sentry-sdk[fastapi]>=2.66.1,<3.0.0",
|
||||||
"pyjwt<3.0.0,>=2.13.0",
|
"pyjwt<3.0.0,>=2.13.0",
|
||||||
"pwdlib[argon2,bcrypt]>=0.3.0",
|
"pwdlib[argon2,bcrypt]>=0.3.0",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
set -e
|
set -e
|
||||||
set -x
|
set -x
|
||||||
|
|
||||||
coverage run -m pytest tests/
|
FASTAPI_ENV=development coverage run -m pytest tests/
|
||||||
coverage report
|
coverage report
|
||||||
coverage html --title "${@-coverage}"
|
coverage html --title "${@-coverage}"
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
proxy:
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
# Listen on port 80, default for HTTP, necessary to redirect to HTTPS
|
||||||
|
- "80:80"
|
||||||
|
# Listen on port 443, default for HTTPS
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
# Mount the volume to store the certificates
|
||||||
|
- traefik-certificates:/certificates
|
||||||
|
command:
|
||||||
|
# Enable Docker in Traefik, so that it reads labels from Docker services
|
||||||
|
- --providers.docker
|
||||||
|
# Do not expose all Docker services, only the ones explicitly exposed
|
||||||
|
- --providers.docker.exposedbydefault=false
|
||||||
|
# Create an entrypoint "http" listening on port 80
|
||||||
|
- --entrypoints.http.address=:80
|
||||||
|
# Redirect all HTTP traffic to HTTPS
|
||||||
|
- --entrypoints.http.http.redirections.entrypoint.to=https
|
||||||
|
- --entrypoints.http.http.redirections.entrypoint.scheme=https
|
||||||
|
# Create an entrypoint "https" listening on port 443
|
||||||
|
- --entrypoints.https.address=:443
|
||||||
|
# Store the Let's Encrypt certificates in the mounted volume
|
||||||
|
- --certificatesresolvers.le.acme.storage=/certificates/acme.json
|
||||||
|
# Use the TLS Challenge for Let's Encrypt
|
||||||
|
- --certificatesresolvers.le.acme.tlschallenge=true
|
||||||
|
# Enable the access log, with HTTP requests
|
||||||
|
- --accesslog
|
||||||
|
# Enable the Traefik log, for configurations and errors
|
||||||
|
- --log
|
||||||
|
|
||||||
|
db:
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
adminer:
|
||||||
|
restart: always
|
||||||
|
labels:
|
||||||
|
# Route HTTPS traffic for the Adminer subdomain
|
||||||
|
- traefik.http.routers.adminer-https.rule=Host(`adminer.${DOMAIN:?Variable not set}`)
|
||||||
|
- traefik.http.routers.adminer-https.entrypoints=https
|
||||||
|
- traefik.http.routers.adminer-https.tls=true
|
||||||
|
# Use the Let's Encrypt resolver
|
||||||
|
- traefik.http.routers.adminer-https.tls.certresolver=le
|
||||||
|
|
||||||
|
backend:
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
FRONTEND_HOST: https://${DOMAIN:?Variable not set}
|
||||||
|
labels:
|
||||||
|
# Route HTTPS traffic for this domain
|
||||||
|
- traefik.http.routers.backend-https.rule=Host(`${DOMAIN:?Variable not set}`)
|
||||||
|
- traefik.http.routers.backend-https.entrypoints=https
|
||||||
|
- traefik.http.routers.backend-https.tls=true
|
||||||
|
# Use the Let's Encrypt resolver
|
||||||
|
- traefik.http.routers.backend-https.tls.certresolver=le
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Create a volume to store the certificates, even if the container is recreated
|
||||||
|
traefik-certificates:
|
||||||
+15
-50
@@ -1,14 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
# Local services are available on their ports, but also available on:
|
|
||||||
# http://api.localhost.tiangolo.com: backend
|
|
||||||
# http://dashboard.localhost.tiangolo.com: frontend
|
|
||||||
# etc. To enable it, update .env, set:
|
|
||||||
# DOMAIN=localhost.tiangolo.com
|
|
||||||
proxy:
|
proxy:
|
||||||
image: traefik:3.6
|
image: traefik:3.6
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "8090:8080"
|
- "8090:8080"
|
||||||
@@ -16,14 +9,10 @@ services:
|
|||||||
command:
|
command:
|
||||||
# Enable Docker in Traefik, so that it reads labels from Docker services
|
# Enable Docker in Traefik, so that it reads labels from Docker services
|
||||||
- --providers.docker
|
- --providers.docker
|
||||||
# Add a constraint to only use services with the label for this stack
|
|
||||||
- --providers.docker.constraints=Label(`traefik.constraint-label`, `traefik-public`)
|
|
||||||
# Do not expose all Docker services, only the ones explicitly exposed
|
# Do not expose all Docker services, only the ones explicitly exposed
|
||||||
- --providers.docker.exposedbydefault=false
|
- --providers.docker.exposedbydefault=false
|
||||||
# Create an entrypoint "http" listening on port 80
|
# Create an entrypoint "http" listening on port 80
|
||||||
- --entrypoints.http.address=:80
|
- --entrypoints.http.address=:80
|
||||||
# Create an entrypoint "https" listening on port 443
|
|
||||||
- --entrypoints.https.address=:443
|
|
||||||
# Enable the access log, with HTTP requests
|
# Enable the access log, with HTTP requests
|
||||||
- --accesslog
|
- --accesslog
|
||||||
# Enable the Traefik log, for configurations and errors
|
# Enable the Traefik log, for configurations and errors
|
||||||
@@ -34,29 +23,15 @@ services:
|
|||||||
- --api
|
- --api
|
||||||
# Enable the Dashboard and API in insecure mode for local development
|
# Enable the Dashboard and API in insecure mode for local development
|
||||||
- --api.insecure=true
|
- --api.insecure=true
|
||||||
labels:
|
|
||||||
# Enable Traefik for this service, to make it available in the public network
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.constraint-label=traefik-public
|
|
||||||
# Dummy https-redirect middleware that doesn't really redirect, only to
|
|
||||||
# allow running it locally
|
|
||||||
- traefik.http.middlewares.https-redirect.contenttype.autodetect=false
|
|
||||||
networks:
|
|
||||||
- traefik-public
|
|
||||||
- default
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
restart: "no"
|
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
|
|
||||||
adminer:
|
adminer:
|
||||||
restart: "no"
|
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
restart: "no"
|
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
build:
|
build:
|
||||||
@@ -65,27 +40,33 @@ services:
|
|||||||
# command: sleep infinity # Infinite loop to keep container alive doing nothing
|
# command: sleep infinity # Infinite loop to keep container alive doing nothing
|
||||||
command:
|
command:
|
||||||
- fastapi
|
- fastapi
|
||||||
- run
|
- dev
|
||||||
- --reload
|
- --host
|
||||||
- "app/main.py"
|
- "0.0.0.0"
|
||||||
develop:
|
develop:
|
||||||
watch:
|
watch:
|
||||||
- path: ./backend
|
- path: ./backend
|
||||||
action: sync
|
action: sync
|
||||||
target: /app/backend
|
target: /app/backend
|
||||||
ignore:
|
ignore:
|
||||||
- ./backend/.venv
|
|
||||||
- .venv
|
- .venv
|
||||||
- path: ./backend/pyproject.toml
|
- path: ./backend/pyproject.toml
|
||||||
action: rebuild
|
action: rebuild
|
||||||
|
- path: ./frontend
|
||||||
|
action: rebuild
|
||||||
|
ignore:
|
||||||
|
- ./frontend/node_modules
|
||||||
|
- ./frontend/dist
|
||||||
|
- ./frontend/blob-report
|
||||||
|
- ./frontend/test-results
|
||||||
# TODO: remove once coverage is done locally
|
# TODO: remove once coverage is done locally
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/htmlcov:/app/backend/htmlcov
|
- ./backend/htmlcov:/app/backend/htmlcov
|
||||||
environment:
|
environment:
|
||||||
|
FASTAPI_ENV: "development"
|
||||||
SMTP_HOST: "mailcatcher"
|
SMTP_HOST: "mailcatcher"
|
||||||
SMTP_PORT: "1025"
|
SMTP_PORT: "1025"
|
||||||
SMTP_TLS: "false"
|
SMTP_TLS: "false"
|
||||||
EMAILS_FROM_EMAIL: "noreply@example.com"
|
|
||||||
|
|
||||||
mailcatcher:
|
mailcatcher:
|
||||||
image: schickling/mailcatcher
|
image: schickling/mailcatcher
|
||||||
@@ -93,43 +74,27 @@ services:
|
|||||||
- "1080:1080"
|
- "1080:1080"
|
||||||
- "1025:1025"
|
- "1025:1025"
|
||||||
|
|
||||||
frontend:
|
|
||||||
restart: "no"
|
|
||||||
ports:
|
|
||||||
- "5173:80"
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: frontend/Dockerfile
|
|
||||||
args:
|
|
||||||
- VITE_API_URL=http://localhost:8000
|
|
||||||
- NODE_ENV=development
|
|
||||||
|
|
||||||
playwright:
|
playwright:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: frontend/Dockerfile.playwright
|
dockerfile: frontend/Dockerfile.playwright
|
||||||
args:
|
args:
|
||||||
- VITE_API_URL=http://backend:8000
|
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
ipc: host
|
ipc: host
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
- mailcatcher
|
- mailcatcher
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
environment:
|
||||||
|
- FIRST_SUPERUSER=${FIRST_SUPERUSER:?Variable not set}
|
||||||
|
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD:?Variable not set}
|
||||||
|
- PLAYWRIGHT_BASE_URL=http://backend:8000
|
||||||
- VITE_API_URL=http://backend:8000
|
- VITE_API_URL=http://backend:8000
|
||||||
- MAILCATCHER_HOST=http://mailcatcher:1080
|
- MAILCATCHER_HOST=http://mailcatcher:1080
|
||||||
# For the reports when run locally
|
# For the reports when run locally
|
||||||
- PLAYWRIGHT_HTML_HOST=0.0.0.0
|
- PLAYWRIGHT_HTML_HOST=0.0.0.0
|
||||||
- CI=${CI}
|
- CI=${CI:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend/blob-report:/app/frontend/blob-report
|
- ./frontend/blob-report:/app/frontend/blob-report
|
||||||
- ./frontend/test-results:/app/frontend/test-results
|
- ./frontend/test-results:/app/frontend/test-results
|
||||||
ports:
|
ports:
|
||||||
- 9323:9323
|
- 9323:9323
|
||||||
|
|
||||||
networks:
|
|
||||||
traefik-public:
|
|
||||||
# For local dev, don't expect an external Traefik network
|
|
||||||
external: false
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
services:
|
|
||||||
traefik:
|
|
||||||
image: traefik:3.6
|
|
||||||
ports:
|
|
||||||
# Listen on port 80, default for HTTP, necessary to redirect to HTTPS
|
|
||||||
- 80:80
|
|
||||||
# Listen on port 443, default for HTTPS
|
|
||||||
- 443:443
|
|
||||||
restart: always
|
|
||||||
labels:
|
|
||||||
# Enable Traefik for this service, to make it available in the public network
|
|
||||||
- traefik.enable=true
|
|
||||||
# Use the traefik-public network (declared below)
|
|
||||||
- traefik.docker.network=traefik-public
|
|
||||||
# Define the port inside of the Docker service to use
|
|
||||||
- traefik.http.services.traefik-dashboard.loadbalancer.server.port=8080
|
|
||||||
# Make Traefik use this domain (from an environment variable) in HTTP
|
|
||||||
- traefik.http.routers.traefik-dashboard-http.entrypoints=http
|
|
||||||
- traefik.http.routers.traefik-dashboard-http.rule=Host(`traefik.${DOMAIN?Variable not set}`)
|
|
||||||
# traefik-https the actual router using HTTPS
|
|
||||||
- traefik.http.routers.traefik-dashboard-https.entrypoints=https
|
|
||||||
- traefik.http.routers.traefik-dashboard-https.rule=Host(`traefik.${DOMAIN?Variable not set}`)
|
|
||||||
- traefik.http.routers.traefik-dashboard-https.tls=true
|
|
||||||
# Use the "le" (Let's Encrypt) resolver created below
|
|
||||||
- traefik.http.routers.traefik-dashboard-https.tls.certresolver=le
|
|
||||||
# Use the special Traefik service api@internal with the web UI/Dashboard
|
|
||||||
- traefik.http.routers.traefik-dashboard-https.service=api@internal
|
|
||||||
# https-redirect middleware to redirect HTTP to HTTPS
|
|
||||||
- traefik.http.middlewares.https-redirect.redirectscheme.scheme=https
|
|
||||||
- traefik.http.middlewares.https-redirect.redirectscheme.permanent=true
|
|
||||||
# traefik-http set up only to use the middleware to redirect to https
|
|
||||||
- traefik.http.routers.traefik-dashboard-http.middlewares=https-redirect
|
|
||||||
# admin-auth middleware with HTTP Basic auth
|
|
||||||
# Using the environment variables USERNAME and HASHED_PASSWORD
|
|
||||||
- traefik.http.middlewares.admin-auth.basicauth.users=${USERNAME?Variable not set}:${HASHED_PASSWORD?Variable not set}
|
|
||||||
# Enable HTTP Basic auth, using the middleware created above
|
|
||||||
- traefik.http.routers.traefik-dashboard-https.middlewares=admin-auth
|
|
||||||
volumes:
|
|
||||||
# Add Docker as a mounted volume, so that Traefik can read the labels of other services
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
# Mount the volume to store the certificates
|
|
||||||
- traefik-public-certificates:/certificates
|
|
||||||
command:
|
|
||||||
# Enable Docker in Traefik, so that it reads labels from Docker services
|
|
||||||
- --providers.docker
|
|
||||||
# Do not expose all Docker services, only the ones explicitly exposed
|
|
||||||
- --providers.docker.exposedbydefault=false
|
|
||||||
# Create an entrypoint "http" listening on port 80
|
|
||||||
- --entrypoints.http.address=:80
|
|
||||||
# Create an entrypoint "https" listening on port 443
|
|
||||||
- --entrypoints.https.address=:443
|
|
||||||
# Create the certificate resolver "le" for Let's Encrypt, uses the environment variable EMAIL
|
|
||||||
- --certificatesresolvers.le.acme.email=${EMAIL?Variable not set}
|
|
||||||
# Store the Let's Encrypt certificates in the mounted volume
|
|
||||||
- --certificatesresolvers.le.acme.storage=/certificates/acme.json
|
|
||||||
# Use the TLS Challenge for Let's Encrypt
|
|
||||||
- --certificatesresolvers.le.acme.tlschallenge=true
|
|
||||||
# Enable the access log, with HTTP requests
|
|
||||||
- --accesslog
|
|
||||||
# Enable the Traefik log, for configurations and errors
|
|
||||||
- --log
|
|
||||||
# Enable the Dashboard and API
|
|
||||||
- --api
|
|
||||||
networks:
|
|
||||||
# Use the public network created to be shared between Traefik and
|
|
||||||
# any other service that needs to be publicly available with HTTPS
|
|
||||||
- traefik-public
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
# Create a volume to store the certificates, even if the container is recreated
|
|
||||||
traefik-public-certificates:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
# Use the previously created public network "traefik-public", shared with other
|
|
||||||
# services that need to be publicly available via this Traefik
|
|
||||||
traefik-public:
|
|
||||||
external: true
|
|
||||||
+44
-130
@@ -1,113 +1,68 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
|
proxy:
|
||||||
|
image: traefik:3.6
|
||||||
|
volumes:
|
||||||
|
# Add Docker as a mounted volume, so that Traefik can read the labels of other services
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
command:
|
||||||
|
# Enable Docker in Traefik, so that it reads labels from Docker services
|
||||||
|
- --providers.docker
|
||||||
|
# Do not expose all Docker services, only the ones explicitly exposed
|
||||||
|
- --providers.docker.exposedbydefault=false
|
||||||
|
# Create an entrypoint "http" listening on port 80
|
||||||
|
- --entrypoints.http.address=:80
|
||||||
|
# Enable the access log, with HTTP requests
|
||||||
|
- --accesslog
|
||||||
|
# Enable the Traefik log, for configurations and errors
|
||||||
|
- --log
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:18
|
image: postgres:18
|
||||||
restart: always
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
test: ["CMD-SHELL", "pg_isready -U postgres -d app"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
volumes:
|
volumes:
|
||||||
- app-db-data:/var/lib/postgresql/data/pgdata
|
- app-db-data:/var/lib/postgresql
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
environment:
|
||||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Variable not set}
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
|
- POSTGRES_DB=app
|
||||||
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
|
|
||||||
- POSTGRES_DB=${POSTGRES_DB?Variable not set}
|
|
||||||
|
|
||||||
adminer:
|
adminer:
|
||||||
image: adminer
|
image: adminer
|
||||||
restart: always
|
|
||||||
networks:
|
|
||||||
- traefik-public
|
|
||||||
- default
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
environment:
|
environment:
|
||||||
- ADMINER_DESIGN=pepa-linha-dark
|
- ADMINER_DESIGN=pepa-linha-dark
|
||||||
labels:
|
labels:
|
||||||
|
# Enable Traefik for this service
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.docker.network=traefik-public
|
# Route HTTP traffic for the Adminer subdomain
|
||||||
- traefik.constraint-label=traefik-public
|
- traefik.http.routers.adminer-http.rule=Host(`adminer.${DOMAIN:-localhost}`)
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.rule=Host(`adminer.${DOMAIN?Variable not set}`)
|
- traefik.http.routers.adminer-http.entrypoints=http
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.entrypoints=http
|
# Define the port inside of the Docker service to use
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.middlewares=https-redirect
|
- traefik.http.services.adminer.loadbalancer.server.port=8080
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.rule=Host(`adminer.${DOMAIN?Variable not set}`)
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.entrypoints=https
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.tls=true
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.tls.certresolver=le
|
|
||||||
- traefik.http.services.${STACK_NAME?Variable not set}-adminer.loadbalancer.server.port=8080
|
|
||||||
|
|
||||||
prestart:
|
|
||||||
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: backend/Dockerfile
|
|
||||||
networks:
|
|
||||||
- traefik-public
|
|
||||||
- default
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: true
|
|
||||||
command: bash scripts/prestart.sh
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
- DOMAIN=${DOMAIN}
|
|
||||||
- FRONTEND_HOST=${FRONTEND_HOST?Variable not set}
|
|
||||||
- ENVIRONMENT=${ENVIRONMENT}
|
|
||||||
- BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS}
|
|
||||||
- SECRET_KEY=${SECRET_KEY?Variable not set}
|
|
||||||
- FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set}
|
|
||||||
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set}
|
|
||||||
- SMTP_HOST=${SMTP_HOST}
|
|
||||||
- SMTP_USER=${SMTP_USER}
|
|
||||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
|
||||||
- EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL}
|
|
||||||
- POSTGRES_SERVER=db
|
|
||||||
- POSTGRES_PORT=${POSTGRES_PORT}
|
|
||||||
- POSTGRES_DB=${POSTGRES_DB}
|
|
||||||
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
|
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
|
|
||||||
- SENTRY_DSN=${SENTRY_DSN}
|
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
|
image: backend:latest
|
||||||
restart: always
|
|
||||||
networks:
|
|
||||||
- traefik-public
|
|
||||||
- default
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: true
|
restart: true
|
||||||
prestart:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
environment:
|
||||||
- DOMAIN=${DOMAIN}
|
PROJECT_NAME: ${PROJECT_NAME:?Variable not set}
|
||||||
- FRONTEND_HOST=${FRONTEND_HOST?Variable not set}
|
SECRET_KEY: ${SECRET_KEY:?Variable not set}
|
||||||
- ENVIRONMENT=${ENVIRONMENT}
|
FIRST_SUPERUSER: ${FIRST_SUPERUSER:?Variable not set}
|
||||||
- BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS}
|
FIRST_SUPERUSER_PASSWORD: ${FIRST_SUPERUSER_PASSWORD:?Variable not set}
|
||||||
- SECRET_KEY=${SECRET_KEY?Variable not set}
|
SMTP_HOST: ${SMTP_HOST}
|
||||||
- FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set}
|
SMTP_USER: ${SMTP_USER:-}
|
||||||
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set}
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
- SMTP_HOST=${SMTP_HOST}
|
EMAILS_FROM_EMAIL: ${EMAILS_FROM_EMAIL}
|
||||||
- SMTP_USER=${SMTP_USER}
|
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:?Variable not set}@db:5432/app
|
||||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
SENTRY_DSN: ${SENTRY_DSN:-}
|
||||||
- EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL}
|
|
||||||
- POSTGRES_SERVER=db
|
|
||||||
- POSTGRES_PORT=${POSTGRES_PORT}
|
|
||||||
- POSTGRES_DB=${POSTGRES_DB}
|
|
||||||
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
|
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
|
|
||||||
- SENTRY_DSN=${SENTRY_DSN}
|
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"]
|
||||||
@@ -119,56 +74,15 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
labels:
|
labels:
|
||||||
|
# Enable Traefik for this service
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.docker.network=traefik-public
|
|
||||||
- traefik.constraint-label=traefik-public
|
|
||||||
|
|
||||||
- traefik.http.services.${STACK_NAME?Variable not set}-backend.loadbalancer.server.port=8000
|
# Define the port inside of the Docker service to use
|
||||||
|
- traefik.http.services.backend.loadbalancer.server.port=8000
|
||||||
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.rule=Host(`api.${DOMAIN?Variable not set}`)
|
# Route HTTP traffic for this domain
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.entrypoints=http
|
- traefik.http.routers.backend-http.rule=Host(`${DOMAIN:-localhost}`)
|
||||||
|
- traefik.http.routers.backend-http.entrypoints=http
|
||||||
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.rule=Host(`api.${DOMAIN?Variable not set}`)
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.entrypoints=https
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls=true
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls.certresolver=le
|
|
||||||
|
|
||||||
# Enable redirection for HTTP and HTTPS
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.middlewares=https-redirect
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}'
|
|
||||||
restart: always
|
|
||||||
networks:
|
|
||||||
- traefik-public
|
|
||||||
- default
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: frontend/Dockerfile
|
|
||||||
args:
|
|
||||||
- VITE_API_URL=https://api.${DOMAIN?Variable not set}
|
|
||||||
- NODE_ENV=production
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.docker.network=traefik-public
|
|
||||||
- traefik.constraint-label=traefik-public
|
|
||||||
|
|
||||||
- traefik.http.services.${STACK_NAME?Variable not set}-frontend.loadbalancer.server.port=80
|
|
||||||
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.rule=Host(`dashboard.${DOMAIN?Variable not set}`)
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.entrypoints=http
|
|
||||||
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.rule=Host(`dashboard.${DOMAIN?Variable not set}`)
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.entrypoints=https
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls=true
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls.certresolver=le
|
|
||||||
|
|
||||||
# Enable redirection for HTTP and HTTPS
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.middlewares=https-redirect
|
|
||||||
volumes:
|
volumes:
|
||||||
app-db-data:
|
app-db-data:
|
||||||
|
|
||||||
networks:
|
|
||||||
traefik-public:
|
|
||||||
# Allow setting it to false for testing
|
|
||||||
external: true
|
|
||||||
|
|||||||
-100
@@ -1,100 +0,0 @@
|
|||||||
project_name:
|
|
||||||
type: str
|
|
||||||
help: The name of the project, shown to API users (in .env)
|
|
||||||
default: FastAPI Project
|
|
||||||
|
|
||||||
stack_name:
|
|
||||||
type: str
|
|
||||||
help: The name of the stack used for Docker Compose labels (no spaces) (in .env)
|
|
||||||
default: fastapi-project
|
|
||||||
|
|
||||||
secret_key:
|
|
||||||
type: str
|
|
||||||
help: |
|
|
||||||
'The secret key for the project, used for security,
|
|
||||||
stored in .env, you can generate one with:
|
|
||||||
python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
|
||||||
default: changethis
|
|
||||||
|
|
||||||
first_superuser:
|
|
||||||
type: str
|
|
||||||
help: The email of the first superuser (in .env)
|
|
||||||
default: admin@example.com
|
|
||||||
|
|
||||||
first_superuser_password:
|
|
||||||
type: str
|
|
||||||
help: The password of the first superuser (in .env)
|
|
||||||
default: changethis
|
|
||||||
|
|
||||||
smtp_host:
|
|
||||||
type: str
|
|
||||||
help: The SMTP server host to send emails, you can set it later in .env
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
smtp_user:
|
|
||||||
type: str
|
|
||||||
help: The SMTP server user to send emails, you can set it later in .env
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
smtp_password:
|
|
||||||
type: str
|
|
||||||
help: The SMTP server password to send emails, you can set it later in .env
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
emails_from_email:
|
|
||||||
type: str
|
|
||||||
help: The email account to send emails from, you can set it later in .env
|
|
||||||
default: info@example.com
|
|
||||||
|
|
||||||
postgres_password:
|
|
||||||
type: str
|
|
||||||
help: |
|
|
||||||
'The password for the PostgreSQL database, stored in .env,
|
|
||||||
you can generate one with:
|
|
||||||
python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
|
||||||
default: changethis
|
|
||||||
|
|
||||||
sentry_dsn:
|
|
||||||
type: str
|
|
||||||
help: The DSN for Sentry, if you are using it, you can set it later in .env
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
_exclude:
|
|
||||||
# Global
|
|
||||||
- .vscode
|
|
||||||
- .mypy_cache
|
|
||||||
# Python
|
|
||||||
- __pycache__
|
|
||||||
- app.egg-info
|
|
||||||
- "*.pyc"
|
|
||||||
- .mypy_cache
|
|
||||||
- .coverage
|
|
||||||
- htmlcov
|
|
||||||
- .cache
|
|
||||||
- .venv
|
|
||||||
# Frontend
|
|
||||||
# Logs
|
|
||||||
- logs
|
|
||||||
- "*.log"
|
|
||||||
- npm-debug.log*
|
|
||||||
- yarn-debug.log*
|
|
||||||
- yarn-error.log*
|
|
||||||
- pnpm-debug.log*
|
|
||||||
- lerna-debug.log*
|
|
||||||
- node_modules
|
|
||||||
- dist
|
|
||||||
- dist-ssr
|
|
||||||
- "*.local"
|
|
||||||
# Editor directories and files
|
|
||||||
- .idea
|
|
||||||
- .DS_Store
|
|
||||||
- "*.suo"
|
|
||||||
- "*.ntvs*"
|
|
||||||
- "*.njsproj"
|
|
||||||
- "*.sln"
|
|
||||||
- "*.sw?"
|
|
||||||
|
|
||||||
_answers_file: .copier/.copier-answers.yml
|
|
||||||
|
|
||||||
_tasks:
|
|
||||||
- ["{{ _copier_python }}", .copier/update_dotenv.py]
|
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# FastAPI Project - Docker Compose Deployment
|
||||||
|
|
||||||
|
You can deploy the project to your own remote server with Docker Compose. The deployment configuration includes Traefik to handle HTTPS and route incoming traffic to the application.
|
||||||
|
|
||||||
|
## Preparation
|
||||||
|
|
||||||
|
* Have a remote server ready and available.
|
||||||
|
* Configure DNS records pointing to the server for the application domain and any supporting service subdomains you want to expose, such as `fastapi-project.example.com` and `adminer.fastapi-project.example.com`.
|
||||||
|
* Install and configure [Docker](https://docs.docker.com/engine/install/) on the remote server (Docker Engine, not Docker Desktop).
|
||||||
|
|
||||||
|
## Copy the Code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rsync -av --exclude=".git/" --filter=":- .gitignore" ./ root@your-server.example.com:/root/code/app/
|
||||||
|
```
|
||||||
|
|
||||||
|
The `--filter=":- .gitignore"` option tells `rsync` to use the same ignore rules as Git, excluding files such as the Python virtual environment.
|
||||||
|
|
||||||
|
## Configure the Application
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Set the application domain, project name, and first superuser email:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DOMAIN=fastapi-project.example.com
|
||||||
|
export PROJECT_NAME="Full Stack FastAPI Project"
|
||||||
|
export FIRST_SUPERUSER=admin@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also configure these environment variables as needed:
|
||||||
|
|
||||||
|
* `SMTP_HOST`: The SMTP server host from your email provider.
|
||||||
|
* `SMTP_USER`: The SMTP server user.
|
||||||
|
* `EMAILS_FROM_EMAIL`: The email account used to send emails.
|
||||||
|
* `SENTRY_DSN`: The DSN for Sentry.
|
||||||
|
|
||||||
|
### Secrets
|
||||||
|
|
||||||
|
Generate and set secure values for the database password, token signing key, and first superuser password:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export POSTGRES_PASSWORD="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||||
|
export SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||||
|
export FIRST_SUPERUSER_PASSWORD="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||||
|
```
|
||||||
|
|
||||||
|
To use an authenticated email provider, also set `SMTP_PASSWORD`.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/code/app/
|
||||||
|
docker compose -f compose.yml -f compose.deploy.yml build
|
||||||
|
docker compose -f compose.yml -f compose.deploy.yml run --rm backend bash scripts/prestart.sh
|
||||||
|
docker compose -f compose.yml -f compose.deploy.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The `compose.deploy.yml` file adds HTTPS and automatic certificate handling to the shared `compose.yml` configuration. Explicitly listing both files excludes the local settings from `compose.override.yml`.
|
||||||
|
|
||||||
|
The backend Docker image builds the frontend, so the server does not need Bun or prebuilt frontend files.
|
||||||
|
|
||||||
|
## Deploy with GitHub Actions
|
||||||
|
|
||||||
|
The included `.github/workflows/deploy-docker-compose.yml` workflow runs the deployment commands on the server when manually triggered from GitHub Actions.
|
||||||
|
|
||||||
|
Use a self-hosted runner only for a repository whose contributors and workflow code you trust. GitHub recommends using self-hosted runners with private repositories because workflows execute directly on the runner machine.
|
||||||
|
|
||||||
|
### Configure Repository Variables and Secrets
|
||||||
|
|
||||||
|
In the repository, go to **Settings** > **Secrets and variables** > **Actions** and add these repository variables:
|
||||||
|
|
||||||
|
* `DOMAIN`
|
||||||
|
* `PROJECT_NAME`
|
||||||
|
* `FIRST_SUPERUSER`
|
||||||
|
|
||||||
|
To enable emails, add these optional repository variables:
|
||||||
|
|
||||||
|
* `SMTP_HOST`
|
||||||
|
* `SMTP_USER`
|
||||||
|
* `EMAILS_FROM_EMAIL`
|
||||||
|
|
||||||
|
To enable Sentry, add the optional `SENTRY_DSN` repository variable.
|
||||||
|
|
||||||
|
Add these repository secrets:
|
||||||
|
|
||||||
|
* `POSTGRES_PASSWORD`
|
||||||
|
* `SECRET_KEY`
|
||||||
|
* `FIRST_SUPERUSER_PASSWORD`
|
||||||
|
|
||||||
|
To use an authenticated email provider, add the optional `SMTP_PASSWORD` repository secret.
|
||||||
|
|
||||||
|
### Install a Self-Hosted Runner
|
||||||
|
|
||||||
|
On the server, create a dedicated user and grant it access to Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo adduser github
|
||||||
|
sudo usermod -aG docker github
|
||||||
|
sudo su - github
|
||||||
|
```
|
||||||
|
|
||||||
|
In the GitHub repository, go to **Settings** > **Actions** > **Runners**, select **New self-hosted runner**, choose Linux, and follow the commands GitHub provides to download, configure, and register the runner. Install it in `/home/github/actions-runner`.
|
||||||
|
|
||||||
|
After registering the runner, exit the `github` user session and install the runner as a system service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
exit
|
||||||
|
cd /home/github/actions-runner
|
||||||
|
sudo ./svc.sh install github
|
||||||
|
sudo ./svc.sh start
|
||||||
|
sudo ./svc.sh status
|
||||||
|
```
|
||||||
|
|
||||||
|
See GitHub's guides for [adding a self-hosted runner](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/add-runners) and [configuring the runner as a service](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/configure-the-application?platform=linux).
|
||||||
|
|
||||||
|
### Run the Deployment
|
||||||
|
|
||||||
|
When the runner is online, open the repository's **Actions** tab, select **Deploy with Docker Compose**, and select **Run workflow**.
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
|
||||||
|
Replace `fastapi-project.example.com` with your domain.
|
||||||
|
|
||||||
|
Application (frontend and API): `https://fastapi-project.example.com`
|
||||||
|
|
||||||
|
Interactive API docs: `https://fastapi-project.example.com/docs`
|
||||||
|
|
||||||
|
Adminer: `https://adminer.fastapi-project.example.com`
|
||||||
+51
-306
@@ -1,352 +1,97 @@
|
|||||||
# FastAPI Project - Deployment
|
# FastAPI Project - Deployment
|
||||||
|
|
||||||
You can deploy the project using Docker Compose to a remote server.
|
Deploy the project to [FastAPI Cloud](https://fastapicloud.com) with the included GitHub Actions workflow.
|
||||||
|
|
||||||
This project expects you to have a Traefik proxy handling communication to the outside world and HTTPS certificates.
|
## Create the FastAPI Cloud Application
|
||||||
|
|
||||||
You can use CI/CD (continuous integration and continuous deployment) systems to deploy automatically, there are already configurations to do it with GitHub Actions.
|
Create an application in FastAPI Cloud and set its [Application Directory](https://fastapicloud.com/docs/builds-and-deployments/application-directory/) to `backend`.
|
||||||
|
|
||||||
But you have to configure a couple things first. 🤓
|
Connect a PostgreSQL database using the [Neon](https://fastapicloud.com/docs/integrations/neon-integration/) or [Supabase](https://fastapicloud.com/docs/integrations/supabase-integration/) integration. Both integrations configure a `DATABASE_URL` secret automatically. You can also configure `DATABASE_URL` manually for another PostgreSQL provider.
|
||||||
|
|
||||||
## Preparation
|
## Configure the Application
|
||||||
|
|
||||||
* Have a remote server ready and available.
|
### Environment Variables
|
||||||
* Configure the DNS records of your domain to point to the IP of the server you just created.
|
|
||||||
* Configure a wildcard subdomain for your domain, so that you can have multiple subdomains for different services, e.g. `*.fastapi-project.example.com`. This will be useful for accessing different components, like `dashboard.fastapi-project.example.com`, `api.fastapi-project.example.com`, `traefik.fastapi-project.example.com`, `adminer.fastapi-project.example.com`, etc. And also for `staging`, like `dashboard.staging.fastapi-project.example.com`, `adminer.staging.fastapi-project.example.com`, etc.
|
|
||||||
* Install and configure [Docker](https://docs.docker.com/engine/install/) on the remote server (Docker Engine, not Docker Desktop).
|
|
||||||
|
|
||||||
## Public Traefik
|
Add these required [environment variables](https://fastapicloud.com/docs/builds-and-deployments/environment-variables/) to the FastAPI Cloud application:
|
||||||
|
|
||||||
We need a Traefik proxy to handle incoming connections and HTTPS certificates.
|
* `PROJECT_NAME`: The name of the project, used in the API documentation and emails.
|
||||||
|
* `FIRST_SUPERUSER`: The email address of the first superuser.
|
||||||
|
* `FRONTEND_HOST`: The public URL of the application, such as the generated `https://your-app.fastapicloud.dev` URL or a custom domain.
|
||||||
|
|
||||||
You need to do these next steps only once.
|
To enable emails, add these optional environment variables with values from your email provider:
|
||||||
|
|
||||||
### Traefik Docker Compose
|
* `SMTP_HOST`
|
||||||
|
* `SMTP_USER`
|
||||||
|
* `EMAILS_FROM_EMAIL`
|
||||||
|
|
||||||
* Create a remote directory to store your Traefik Docker Compose file:
|
To enable Sentry, configure `SENTRY_DSN`.
|
||||||
|
|
||||||
```bash
|
### Secrets
|
||||||
mkdir -p /root/code/traefik-public/
|
|
||||||
```
|
|
||||||
|
|
||||||
Copy the Traefik Docker Compose file to your server. You could do it by running the command `rsync` in your local terminal:
|
Add these required values and mark them as secrets:
|
||||||
|
|
||||||
```bash
|
* `SECRET_KEY`: A secret key used to sign security tokens.
|
||||||
rsync -a compose.traefik.yml root@your-server.example.com:/root/code/traefik-public/
|
* `FIRST_SUPERUSER_PASSWORD`: The password of the first superuser.
|
||||||
```
|
* `DATABASE_URL`: The PostgreSQL connection URL, configured automatically when using a database integration.
|
||||||
|
|
||||||
### Traefik Public Network
|
To enable emails with an authenticated provider, add `SMTP_PASSWORD` as a secret.
|
||||||
|
|
||||||
This Traefik will expect a Docker "public network" named `traefik-public` to communicate with your stack(s).
|
You can generate secure values for `SECRET_KEY` and `FIRST_SUPERUSER_PASSWORD` with:
|
||||||
|
|
||||||
This way, there will be a single public Traefik proxy that handles the communication (HTTP and HTTPS) with the outside world, and then behind that, you could have one or more stacks with different domains, even if they are on the same single server.
|
|
||||||
|
|
||||||
To create a Docker "public network" named `traefik-public` run the following command in your remote server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker network create traefik-public
|
|
||||||
```
|
|
||||||
|
|
||||||
### Traefik Environment Variables
|
|
||||||
|
|
||||||
The Traefik Docker Compose file expects some environment variables to be set in your terminal before starting it. You can do it by running the following commands in your remote server.
|
|
||||||
|
|
||||||
* Create the username for HTTP Basic Auth, e.g.:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export USERNAME=admin
|
|
||||||
```
|
|
||||||
|
|
||||||
* Create an environment variable with the password for HTTP Basic Auth, e.g.:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export PASSWORD=changethis
|
|
||||||
```
|
|
||||||
|
|
||||||
* Use openssl to generate the "hashed" version of the password for HTTP Basic Auth and store it in an environment variable:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export HASHED_PASSWORD=$(openssl passwd -apr1 $PASSWORD)
|
|
||||||
```
|
|
||||||
|
|
||||||
To verify that the hashed password is correct, you can print it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
echo $HASHED_PASSWORD
|
|
||||||
```
|
|
||||||
|
|
||||||
* Create an environment variable with the domain name for your server, e.g.:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export DOMAIN=fastapi-project.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
* Create an environment variable with the email for Let's Encrypt, e.g.:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export EMAIL=admin@example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: you need to set a different email, an email `@example.com` won't work.
|
|
||||||
|
|
||||||
### Start the Traefik Docker Compose
|
|
||||||
|
|
||||||
Go to the directory where you copied the Traefik Docker Compose file in your remote server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /root/code/traefik-public/
|
|
||||||
```
|
|
||||||
|
|
||||||
Now with the environment variables set and the `compose.traefik.yml` in place, you can start the Traefik Docker Compose running the following command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f compose.traefik.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deploy the FastAPI Project
|
|
||||||
|
|
||||||
Now that you have Traefik in place you can deploy your FastAPI project with Docker Compose.
|
|
||||||
|
|
||||||
**Note**: You might want to jump ahead to the section about Continuous Deployment with GitHub Actions.
|
|
||||||
|
|
||||||
## Copy the Code
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rsync -av --filter=":- .gitignore" ./ root@your-server.example.com:/root/code/app/
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: `--filter=":- .gitignore"` tells `rsync` to use the same rules as git, ignore files ignored by git, like the Python virtual environment.
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
You need to set some environment variables first.
|
|
||||||
|
|
||||||
### Generate secret keys
|
|
||||||
|
|
||||||
Some environment variables in the `.env` file have a default value of `changethis`.
|
|
||||||
|
|
||||||
You have to change them with a secret key, to generate secret keys you can run the following command:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -c "import secrets; print(secrets.token_urlsafe(32))"
|
python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
```
|
```
|
||||||
|
|
||||||
Copy the content and use that as password / secret key. And run that again to generate another secure key.
|
## Configure Continuous Deployment
|
||||||
|
|
||||||
### Required Environment Variables
|
The included `.github/workflows/deploy.yml` workflow builds the frontend, prepares the database, and deploys the application whenever changes are pushed to `master`. You can also run it manually from the **Actions** tab.
|
||||||
|
|
||||||
Set the `ENVIRONMENT`, by default `local` (for development), but when deploying to a server you would put something like `staging` or `production`:
|
Log in to FastAPI Cloud and configure the [deploy token](https://fastapicloud.com/docs/advanced-features/deploy-tokens/) and application ID as GitHub repository secrets:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export ENVIRONMENT=production
|
uv run fastapi login
|
||||||
|
uv run fastapi cloud setup-ci --secrets-only --app-id <your-app-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
Set the `DOMAIN`, by default `localhost` (for development), but when deploying you would use your own domain, for example:
|
If the GitHub CLI is installed and authenticated, the command configures `FASTAPI_CLOUD_TOKEN` and `FASTAPI_CLOUD_APP_ID` automatically. Otherwise, it prints the values so you can add them in your repository under **Settings** > **Secrets and variables** > **Actions**.
|
||||||
|
|
||||||
```bash
|
The workflow runs database migrations and creates the first superuser before deploying. In the repository's **Settings** > **Secrets and variables** > **Actions** page, add these repository variables:
|
||||||
export DOMAIN=fastapi-project.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Set the `POSTGRES_PASSWORD` to something different than `changethis`:
|
* `PROJECT_NAME`
|
||||||
|
|
||||||
```bash
|
|
||||||
export POSTGRES_PASSWORD="changethis"
|
|
||||||
```
|
|
||||||
|
|
||||||
Set the `SECRET_KEY`, used to sign tokens:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export SECRET_KEY="changethis"
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: you can use the Python command above to generate a secure secret key.
|
|
||||||
|
|
||||||
Set the `FIRST_SUPER_USER_PASSWORD` to something different than `changethis`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export FIRST_SUPERUSER_PASSWORD="changethis"
|
|
||||||
```
|
|
||||||
|
|
||||||
Set the `BACKEND_CORS_ORIGINS` to include your domain:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export BACKEND_CORS_ORIGINS="https://dashboard.${DOMAIN?Variable not set},https://api.${DOMAIN?Variable not set}"
|
|
||||||
```
|
|
||||||
|
|
||||||
You can set several other environment variables:
|
|
||||||
|
|
||||||
* `PROJECT_NAME`: The name of the project, used in the API for the docs and emails.
|
|
||||||
* `STACK_NAME`: The name of the stack used for Docker Compose labels and project name, this should be different for `staging`, `production`, etc. You could use the same domain replacing dots with dashes, e.g. `fastapi-project-example-com` and `staging-fastapi-project-example-com`.
|
|
||||||
* `BACKEND_CORS_ORIGINS`: A list of allowed CORS origins separated by commas.
|
|
||||||
* `FIRST_SUPERUSER`: The email of the first superuser, this superuser will be the one that can create new users.
|
|
||||||
* `SMTP_HOST`: The SMTP server host to send emails, this would come from your email provider (E.g. Mailgun, Sparkpost, Sendgrid, etc).
|
|
||||||
* `SMTP_USER`: The SMTP server user to send emails.
|
|
||||||
* `SMTP_PASSWORD`: The SMTP server password to send emails.
|
|
||||||
* `EMAILS_FROM_EMAIL`: The email account to send emails from.
|
|
||||||
* `POSTGRES_SERVER`: The hostname of the PostgreSQL server. You can leave the default of `db`, provided by the same Docker Compose. You normally wouldn't need to change this unless you are using a third-party provider.
|
|
||||||
* `POSTGRES_PORT`: The port of the PostgreSQL server. You can leave the default. You normally wouldn't need to change this unless you are using a third-party provider.
|
|
||||||
* `POSTGRES_USER`: The Postgres user, you can leave the default.
|
|
||||||
* `POSTGRES_DB`: The database name to use for this application. You can leave the default of `app`.
|
|
||||||
* `SENTRY_DSN`: The DSN for Sentry, if you are using it.
|
|
||||||
|
|
||||||
## GitHub Actions Environment Variables
|
|
||||||
|
|
||||||
There are some environment variables only used by GitHub Actions that you can configure:
|
|
||||||
|
|
||||||
* `LATEST_CHANGES`: Used by the GitHub Action [latest-changes](https://github.com/tiangolo/latest-changes) to automatically add release notes based on the PRs merged. It's a personal access token, read the docs for details.
|
|
||||||
* `SMOKESHOW_AUTH_KEY`: Used to handle and publish the code coverage using [Smokeshow](https://github.com/samuelcolvin/smokeshow), follow their instructions to create a (free) Smokeshow key.
|
|
||||||
|
|
||||||
### Deploy with Docker Compose
|
|
||||||
|
|
||||||
With the environment variables in place, you can deploy with Docker Compose:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /root/code/app/
|
|
||||||
docker compose -f compose.yml build
|
|
||||||
docker compose -f compose.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
For production you wouldn't want to have the overrides in `compose.override.yml`, that's why we explicitly specify `compose.yml` as the file to use.
|
|
||||||
|
|
||||||
## Continuous Deployment (CD)
|
|
||||||
|
|
||||||
You can use GitHub Actions to deploy your project automatically. 😎
|
|
||||||
|
|
||||||
You can have multiple environment deployments.
|
|
||||||
|
|
||||||
There are already two environments configured, `staging` and `production`. 🚀
|
|
||||||
|
|
||||||
### Install GitHub Actions Runner
|
|
||||||
|
|
||||||
* On your remote server, create a user for your GitHub Actions:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo adduser github
|
|
||||||
```
|
|
||||||
|
|
||||||
* Add Docker permissions to the `github` user:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo usermod -aG docker github
|
|
||||||
```
|
|
||||||
|
|
||||||
* Temporarily switch to the `github` user:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo su - github
|
|
||||||
```
|
|
||||||
|
|
||||||
* Go to the `github` user's home directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd
|
|
||||||
```
|
|
||||||
|
|
||||||
* [Install a GitHub Action self-hosted runner following the official guide](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/adding-self-hosted-runners#adding-a-self-hosted-runner-to-a-repository).
|
|
||||||
|
|
||||||
* When asked about labels, add a label for the environment, e.g. `production`. You can also add labels later.
|
|
||||||
|
|
||||||
After installing, the guide would tell you to run a command to start the runner. Nevertheless, it would stop once you terminate that process or if your local connection to your server is lost.
|
|
||||||
|
|
||||||
To make sure it runs on startup and continues running, you can install it as a service. To do that, exit the `github` user and go back to the `root` user:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
exit
|
|
||||||
```
|
|
||||||
|
|
||||||
After you do it, you will be on the previous user again. And you will be on the previous directory, belonging to that user.
|
|
||||||
|
|
||||||
Before being able to go the `github` user directory, you need to become the `root` user (you might already be):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo su
|
|
||||||
```
|
|
||||||
|
|
||||||
* As the `root` user, go to the `actions-runner` directory inside of the `github` user's home directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/github/actions-runner
|
|
||||||
```
|
|
||||||
|
|
||||||
* Install the self-hosted runner as a service with the user `github`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./svc.sh install github
|
|
||||||
```
|
|
||||||
|
|
||||||
* Start the service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./svc.sh start
|
|
||||||
```
|
|
||||||
|
|
||||||
* Check the status of the service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./svc.sh status
|
|
||||||
```
|
|
||||||
|
|
||||||
You can read more about it in the official guide: [Configuring the self-hosted runner application as a service](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/configuring-the-self-hosted-runner-application-as-a-service).
|
|
||||||
|
|
||||||
### Configure GitHub Environments
|
|
||||||
|
|
||||||
The deployment workflows use [GitHub Environments](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments) for `staging` and `production`. This enables environment-specific secrets, deployment protection rules (e.g. required reviewers, wait timers), and deployment status tracking.
|
|
||||||
|
|
||||||
To configure them, go to your repository's **Settings** > **Environments** and create the `staging` and `production` environments.
|
|
||||||
|
|
||||||
### Set Secrets
|
|
||||||
|
|
||||||
For each GitHub Environment (`staging` and `production`), configure the required secrets as [environment secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets#creating-secrets-for-an-environment). Environment secrets are preferred over [repository secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets#creating-secrets-for-a-repository) because they are scoped to the specific environment, reducing exposure and aligning with any protection rules you configure.
|
|
||||||
|
|
||||||
The current Github Actions workflows expect these secrets:
|
|
||||||
|
|
||||||
* `DOMAIN_PRODUCTION`
|
|
||||||
* `DOMAIN_STAGING`
|
|
||||||
* `STACK_NAME_PRODUCTION`
|
|
||||||
* `STACK_NAME_STAGING`
|
|
||||||
* `EMAILS_FROM_EMAIL`
|
|
||||||
* `FIRST_SUPERUSER`
|
* `FIRST_SUPERUSER`
|
||||||
* `FIRST_SUPERUSER_PASSWORD`
|
|
||||||
* `POSTGRES_PASSWORD`
|
Add these repository secrets:
|
||||||
|
|
||||||
|
* `DATABASE_URL`
|
||||||
* `SECRET_KEY`
|
* `SECRET_KEY`
|
||||||
* `LATEST_CHANGES`
|
* `FIRST_SUPERUSER_PASSWORD`
|
||||||
* `SMOKESHOW_AUTH_KEY`
|
|
||||||
|
|
||||||
## GitHub Action Deployment Workflows
|
Use the same values configured in FastAPI Cloud. For `DATABASE_URL`, use the connection URL from your database provider. The database must be reachable from GitHub-hosted runners so the preparation step can connect to it.
|
||||||
|
|
||||||
There are GitHub Action workflows in the `.github/workflows` directory already configured for deploying to the environments (GitHub Actions runners with the labels):
|
The deployment workflow performs these steps:
|
||||||
|
|
||||||
* `staging`: after pushing (or merging) to the branch `master`.
|
1. Installs and builds the frontend into `backend/app/frontend`.
|
||||||
* `production`: after publishing a release.
|
2. Runs `backend/scripts/prestart.sh` to apply database migrations and create the first superuser.
|
||||||
|
3. Deploys the project with `uv run fastapi deploy`.
|
||||||
Both workflows are associated with their respective GitHub Environments, so deployments will be visible in the repository's **Environments** section and will respect any protection rules you configure.
|
|
||||||
|
|
||||||
If you need to add extra environments you could use those as a starting point.
|
|
||||||
|
|
||||||
## URLs
|
## URLs
|
||||||
|
|
||||||
Replace `fastapi-project.example.com` with your domain.
|
Replace `your-app.fastapicloud.dev` with the URL of your FastAPI Cloud application.
|
||||||
|
|
||||||
### Main Traefik Dashboard
|
Application (frontend and API): `https://your-app.fastapicloud.dev`
|
||||||
|
|
||||||
Traefik UI: `https://traefik.fastapi-project.example.com`
|
Interactive API docs: `https://your-app.fastapicloud.dev/docs`
|
||||||
|
|
||||||
### Production
|
## Docker Compose
|
||||||
|
|
||||||
Frontend: `https://dashboard.fastapi-project.example.com`
|
For deployment to your own server, see the [Docker Compose deployment guide](./deployment-docker-compose.md).
|
||||||
|
|
||||||
Backend API docs: `https://api.fastapi-project.example.com/docs`
|
## GitHub Repository Automation
|
||||||
|
|
||||||
Backend API base URL: `https://api.fastapi-project.example.com`
|
Install the following GitHub Apps to enable the included repository automation:
|
||||||
|
|
||||||
Adminer: `https://adminer.fastapi-project.example.com`
|
* [Latest Changes](https://github.com/apps/latest-changes) updates `release-notes.md` when a pull request is merged.
|
||||||
|
* [PR Push](https://github.com/apps/pr-push) lets the pre-commit workflow push automated fixes to pull request branches.
|
||||||
|
* [PR Submit](https://github.com/apps/pr-submit) lets the **Bump pre-commit hooks** and **Prepare Release** workflows create pull requests.
|
||||||
|
|
||||||
### Staging
|
To publish code coverage with [Smokeshow](https://github.com/samuelcolvin/smokeshow), add `SMOKESHOW_AUTH_KEY` as a repository secret.
|
||||||
|
|
||||||
Frontend: `https://dashboard.staging.fastapi-project.example.com`
|
|
||||||
|
|
||||||
Backend API docs: `https://api.staging.fastapi-project.example.com/docs`
|
|
||||||
|
|
||||||
Backend API base URL: `https://api.staging.fastapi-project.example.com`
|
|
||||||
|
|
||||||
Adminer: `https://adminer.staging.fastapi-project.example.com`
|
|
||||||
|
|||||||
+68
-114
@@ -1,111 +1,85 @@
|
|||||||
# FastAPI Project - Development
|
# FastAPI Project - Development
|
||||||
|
|
||||||
## Docker Compose
|
## Local Development
|
||||||
|
|
||||||
* Start the local stack with Docker Compose:
|
For local development, run PostgreSQL and Mailcatcher with Docker Compose, and run the FastAPI and Vite development servers locally.
|
||||||
|
|
||||||
|
Start the supporting services:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
docker compose up -d db mailcatcher
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, from the `backend` directory, install the dependencies and prepare the database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
uv run bash scripts/prestart.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the FastAPI development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run fastapi dev
|
||||||
|
```
|
||||||
|
|
||||||
|
In another terminal, from the project root, install the frontend dependencies and start the Vite development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Now you can open these URLs:
|
||||||
|
|
||||||
|
Frontend development server: <http://localhost:5173>
|
||||||
|
|
||||||
|
Backend API: <http://localhost:8000>
|
||||||
|
|
||||||
|
Automatic interactive API documentation with Swagger UI: <http://localhost:8000/docs>
|
||||||
|
|
||||||
|
Mailcatcher: <http://localhost:1080>
|
||||||
|
|
||||||
|
The frontend development server uses the backend at `http://localhost:8000`, as configured in `frontend/.env`.
|
||||||
|
|
||||||
|
### Frontend Served by FastAPI
|
||||||
|
|
||||||
|
Build the frontend from the `frontend` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The build is written to `backend/app/frontend` and served by FastAPI at <http://localhost:8000>. Rebuild the frontend after making frontend changes.
|
||||||
|
|
||||||
|
## Full Stack with Docker Compose
|
||||||
|
|
||||||
|
To run the backend and built frontend in Docker Compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm backend bash scripts/prestart.sh
|
||||||
docker compose watch
|
docker compose watch
|
||||||
```
|
```
|
||||||
|
|
||||||
* Now you can open your browser and interact with these URLs:
|
Now you can open these URLs:
|
||||||
|
|
||||||
Frontend, built with Docker, with routes handled based on the path: <http://localhost:5173>
|
Application, with the frontend and API served by FastAPI: <http://localhost:8000>
|
||||||
|
|
||||||
Backend, JSON based web API based on OpenAPI: <http://localhost:8000>
|
Automatic interactive API documentation with Swagger UI: <http://localhost:8000/docs>
|
||||||
|
|
||||||
Automatic interactive documentation with Swagger UI (from the OpenAPI backend): <http://localhost:8000/docs>
|
|
||||||
|
|
||||||
Adminer, database web administration: <http://localhost:8080>
|
Adminer, database web administration: <http://localhost:8080>
|
||||||
|
|
||||||
Traefik UI, to see how the routes are being handled by the proxy: <http://localhost:8090>
|
Traefik UI, to see how the routes are being handled by the proxy: <http://localhost:8090>
|
||||||
|
|
||||||
**Note**: The first time you start your stack, it might take a minute for it to be ready. While the backend waits for the database to be ready and configures everything. You can check the logs to monitor it.
|
Mailcatcher: <http://localhost:1080>
|
||||||
|
|
||||||
To check the logs, run (in another terminal):
|
Stop a locally running FastAPI server before starting the Compose backend because both use port `8000`.
|
||||||
|
|
||||||
```bash
|
**Note**: The first time you start the stack, it might take a minute for all the services to be ready. To monitor it, use `docker compose logs`, or `docker compose logs backend` for the backend service.
|
||||||
docker compose logs
|
|
||||||
```
|
|
||||||
|
|
||||||
To check the logs of a specific service, add the name of the service, e.g.:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose logs backend
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mailcatcher
|
## Mailcatcher
|
||||||
|
|
||||||
Mailcatcher is a simple SMTP server that catches all emails sent by the backend during local development. Instead of sending real emails, they are captured and displayed in a web interface.
|
Mailcatcher captures emails sent during local development instead of delivering them. The local backend connects to it at `localhost:1025`, and the Compose backend connects to the `mailcatcher` service. Captured emails are available at <http://localhost:1080>.
|
||||||
|
|
||||||
This is useful for:
|
|
||||||
|
|
||||||
* Testing email functionality during development
|
|
||||||
* Verifying email content and formatting
|
|
||||||
* Debugging email-related functionality without sending real emails
|
|
||||||
|
|
||||||
The backend is automatically configured to use Mailcatcher when running with Docker Compose locally (SMTP on port 1025). All captured emails can be viewed at <http://localhost:1080>.
|
|
||||||
|
|
||||||
## Local Development
|
|
||||||
|
|
||||||
The Docker Compose files are configured so that each of the services is available in a different port in `localhost`.
|
|
||||||
|
|
||||||
For the backend and frontend, they use the same port that would be used by their local development server, so, the backend is at `http://localhost:8000` and the frontend at `http://localhost:5173`.
|
|
||||||
|
|
||||||
This way, you could turn off a Docker Compose service and start its local development service, and everything would keep working, because it all uses the same ports.
|
|
||||||
|
|
||||||
For example, you can stop that `frontend` service in the Docker Compose, in another terminal, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose stop frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
And then start the local frontend development server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Or you could stop the `backend` Docker Compose service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose stop backend
|
|
||||||
```
|
|
||||||
|
|
||||||
And then you can run the local development server for the backend:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
fastapi dev app/main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker Compose in `localhost.tiangolo.com`
|
|
||||||
|
|
||||||
When you start the Docker Compose stack, it uses `localhost` by default, with different ports for each service (backend, frontend, adminer, etc).
|
|
||||||
|
|
||||||
When you deploy it to production (or staging), it will deploy each service in a different subdomain, like `api.example.com` for the backend and `dashboard.example.com` for the frontend.
|
|
||||||
|
|
||||||
In the guide about [deployment](deployment.md) you can read about Traefik, the configured proxy. That's the component in charge of transmitting traffic to each service based on the subdomain.
|
|
||||||
|
|
||||||
If you want to test that it's all working locally, you can edit the local `.env` file, and change:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
DOMAIN=localhost.tiangolo.com
|
|
||||||
```
|
|
||||||
|
|
||||||
That will be used by the Docker Compose files to configure the base domain for the services.
|
|
||||||
|
|
||||||
Traefik will use this to transmit traffic at `api.localhost.tiangolo.com` to the backend, and traffic at `dashboard.localhost.tiangolo.com` to the frontend.
|
|
||||||
|
|
||||||
The domain `localhost.tiangolo.com` is a special domain that is configured (with all its subdomains) to point to `127.0.0.1`. This way you can use that for your local development.
|
|
||||||
|
|
||||||
After you update it, run again:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose watch
|
|
||||||
```
|
|
||||||
|
|
||||||
When deploying, for example in production, the main Traefik is configured outside of the Docker Compose files. For local development, there's an included Traefik in `compose.override.yml`, just to let you test that the domains work as expected, for example with `api.localhost.tiangolo.com` and `dashboard.localhost.tiangolo.com`.
|
|
||||||
|
|
||||||
## Docker Compose files and env vars
|
## Docker Compose files and env vars
|
||||||
|
|
||||||
@@ -113,9 +87,9 @@ There is a main `compose.yml` file with all the configurations that apply to the
|
|||||||
|
|
||||||
And there's also a `compose.override.yml` with overrides for development, for example to mount the source code as a volume. It is used automatically by `docker compose` to apply overrides on top of `compose.yml`.
|
And there's also a `compose.override.yml` with overrides for development, for example to mount the source code as a volume. It is used automatically by `docker compose` to apply overrides on top of `compose.yml`.
|
||||||
|
|
||||||
These Docker Compose files use the `.env` file containing configurations to be injected as environment variables in the containers.
|
The `compose.deploy.yml` file contains the deployment-specific settings, including HTTPS and automatic certificate handling. It is explicitly combined with `compose.yml` when deploying the application.
|
||||||
|
|
||||||
They also use some additional configurations taken from environment variables set in the scripts before calling the `docker compose` command.
|
The backend reads local settings from the `.env` file. Docker Compose also uses it for variable interpolation and passes the settings each container needs.
|
||||||
|
|
||||||
After changing variables, make sure you restart the stack:
|
After changing variables, make sure you restart the stack:
|
||||||
|
|
||||||
@@ -125,11 +99,11 @@ docker compose watch
|
|||||||
|
|
||||||
## The .env file
|
## The .env file
|
||||||
|
|
||||||
The `.env` file is the one that contains all your configurations, generated keys and passwords, etc.
|
The `.env` file contains the shared local defaults, generated keys, passwords, and other configuration. Its hostnames use `localhost` for processes running on your machine. Docker Compose overrides hostnames such as the database and SMTP server with their Compose service names.
|
||||||
|
|
||||||
Depending on your workflow, you could want to exclude it from Git, for example if your project is public. In that case, you would have to make sure to set up a way for your CI tools to obtain it while building or deploying your project.
|
Depending on your workflow, you could want to exclude it from Git, for example if your project is public. In that case, you would have to make sure to set up a way for your CI tools to obtain it while building or deploying your project.
|
||||||
|
|
||||||
One way to do it could be to add each environment variable to your CI/CD system, and updating the `compose.yml` file to read that specific env var instead of reading the `.env` file.
|
One way to do it could be to add each environment variable to your CI/CD system.
|
||||||
|
|
||||||
## Pre-commits and code linting
|
## Pre-commits and code linting
|
||||||
|
|
||||||
@@ -182,15 +156,13 @@ biome check..............................................................Passed
|
|||||||
|
|
||||||
## URLs
|
## URLs
|
||||||
|
|
||||||
The production or staging URLs would use these same paths, but with your own domain.
|
The deployed URLs use these same paths, but with your own domain.
|
||||||
|
|
||||||
### Development URLs
|
### Development URLs
|
||||||
|
|
||||||
Development URLs, for local development.
|
Development URLs, for local development.
|
||||||
|
|
||||||
Frontend: <http://localhost:5173>
|
Application: <http://localhost:8000>
|
||||||
|
|
||||||
Backend: <http://localhost:8000>
|
|
||||||
|
|
||||||
Automatic Interactive Docs (Swagger UI): <http://localhost:8000/docs>
|
Automatic Interactive Docs (Swagger UI): <http://localhost:8000/docs>
|
||||||
|
|
||||||
@@ -201,21 +173,3 @@ Adminer: <http://localhost:8080>
|
|||||||
Traefik UI: <http://localhost:8090>
|
Traefik UI: <http://localhost:8090>
|
||||||
|
|
||||||
MailCatcher: <http://localhost:1080>
|
MailCatcher: <http://localhost:1080>
|
||||||
|
|
||||||
### Development URLs with `localhost.tiangolo.com` Configured
|
|
||||||
|
|
||||||
Development URLs, for local development.
|
|
||||||
|
|
||||||
Frontend: <http://dashboard.localhost.tiangolo.com>
|
|
||||||
|
|
||||||
Backend: <http://api.localhost.tiangolo.com>
|
|
||||||
|
|
||||||
Automatic Interactive Docs (Swagger UI): <http://api.localhost.tiangolo.com/docs>
|
|
||||||
|
|
||||||
Automatic Alternative Docs (ReDoc): <http://api.localhost.tiangolo.com/redoc>
|
|
||||||
|
|
||||||
Adminer: <http://localhost.tiangolo.com:8080>
|
|
||||||
|
|
||||||
Traefik UI: <http://localhost.tiangolo.com:8090>
|
|
||||||
|
|
||||||
MailCatcher: <http://localhost.tiangolo.com:1080>
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
# Stage 0, "build-stage", based on Bun, to build and compile the frontend
|
|
||||||
FROM oven/bun:1 AS build-stage
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package.json bun.lock /app/
|
|
||||||
|
|
||||||
COPY frontend/package.json /app/frontend/
|
|
||||||
|
|
||||||
WORKDIR /app/frontend
|
|
||||||
|
|
||||||
RUN bun install
|
|
||||||
|
|
||||||
COPY ./frontend /app/frontend
|
|
||||||
ARG VITE_API_URL
|
|
||||||
|
|
||||||
RUN bun run build
|
|
||||||
|
|
||||||
|
|
||||||
# Stage 1, based on Nginx, to have only the compiled app, ready for production with Nginx
|
|
||||||
FROM nginx:1
|
|
||||||
|
|
||||||
COPY --from=build-stage /app/frontend/dist/ /usr/share/nginx/html
|
|
||||||
|
|
||||||
COPY ./frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
COPY ./frontend/nginx-backend-not-found.conf /etc/nginx/extra-conf.d/backend-not-found.conf
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM mcr.microsoft.com/playwright:v1.61.1-noble
|
FROM mcr.microsoft.com/playwright:v1.62.1-noble
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -17,5 +17,3 @@ WORKDIR /app/frontend
|
|||||||
RUN bun install
|
RUN bun install
|
||||||
|
|
||||||
COPY ./frontend /app/frontend
|
COPY ./frontend /app/frontend
|
||||||
|
|
||||||
ARG VITE_API_URL
|
|
||||||
|
|||||||
+16
-17
@@ -15,7 +15,9 @@ bun run dev
|
|||||||
|
|
||||||
* Then open your browser at http://localhost:5173/.
|
* Then open your browser at http://localhost:5173/.
|
||||||
|
|
||||||
Notice that this live server is not running inside Docker, it's for local development, and that is the recommended workflow. Once you are happy with your frontend, you can build the frontend Docker image and start it, to test it in a production-like environment. But building the image at every change will not be as productive as running the local development server with live reload.
|
Run `uv run bash scripts/prestart.sh` and `uv run fastapi dev` from the `backend` directory, with PostgreSQL running in Docker Compose. See [../development.md](../development.md) for the complete setup.
|
||||||
|
|
||||||
|
To serve the frontend with FastAPI, run `bun run build` from the `frontend` directory and open `http://localhost:8000`.
|
||||||
|
|
||||||
Check the file `package.json` to see other available options.
|
Check the file `package.json` to see other available options.
|
||||||
|
|
||||||
@@ -25,26 +27,22 @@ If you are developing an API-only app and want to remove the frontend, you can d
|
|||||||
|
|
||||||
* Remove the `./frontend` directory.
|
* Remove the `./frontend` directory.
|
||||||
|
|
||||||
* In the `compose.yml` file, remove the whole service / section `frontend`.
|
* In the `backend/app/main.py` file, remove the `app.frontend()` call.
|
||||||
|
|
||||||
* In the `compose.override.yml` file, remove the whole service / section `frontend` and `playwright`.
|
* In the `backend/Dockerfile` file, remove the frontend build stage and the `COPY --from=frontend-build` instruction.
|
||||||
|
|
||||||
|
* In the `compose.override.yml` file, remove the `playwright` service.
|
||||||
|
|
||||||
|
* In the `.github/workflows/deploy.yml` file, remove the **Set up Bun**, **Install frontend dependencies**, and **Build frontend** steps.
|
||||||
|
|
||||||
|
* In the `.fastapicloudignore` file, remove the `!backend/app/frontend/` entry.
|
||||||
|
|
||||||
Done, you have a frontend-less (api-only) app. 🤓
|
Done, you have a frontend-less (api-only) app. 🤓
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
If you want, you can also remove the `FRONTEND` environment variables from:
|
|
||||||
|
|
||||||
* `.env`
|
|
||||||
* `./scripts/*.sh`
|
|
||||||
|
|
||||||
But it would be only to clean them up, leaving them won't really have any effect either way.
|
|
||||||
|
|
||||||
## Generate Client
|
## Generate Client
|
||||||
|
|
||||||
### Automatically
|
### Automatically
|
||||||
|
|
||||||
* Activate the backend virtual environment.
|
|
||||||
* From the top level project directory, run the script:
|
* From the top level project directory, run the script:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -55,9 +53,9 @@ bash ./scripts/generate-client.sh
|
|||||||
|
|
||||||
### Manually
|
### Manually
|
||||||
|
|
||||||
* Start the Docker Compose stack.
|
* Make sure the backend is running.
|
||||||
|
|
||||||
* Download the OpenAPI JSON file from `http://localhost/api/v1/openapi.json` and copy it to a new file `openapi.json` at the root of the `frontend` directory.
|
* Download the OpenAPI JSON file from `http://localhost:8000/api/v1/openapi.json` and copy it to a new file `openapi.json` at the root of the `frontend` directory.
|
||||||
|
|
||||||
* To generate the frontend client, run:
|
* To generate the frontend client, run:
|
||||||
|
|
||||||
@@ -71,10 +69,10 @@ Notice that everytime the backend changes (changing the OpenAPI schema), you sho
|
|||||||
|
|
||||||
## Using a Remote API
|
## Using a Remote API
|
||||||
|
|
||||||
If you want to use a remote API, you can set the environment variable `VITE_API_URL` to the URL of the remote API. For example, you can set it in the `frontend/.env` file:
|
By default, the built frontend uses the same origin as the FastAPI app. If you want to use a remote API while running the Vite development server, you can set the environment variable `VITE_API_URL` to the URL of the remote API. For example, you can set it in the `frontend/.env` file:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
VITE_API_URL=https://api.my-domain.example.com
|
VITE_API_URL=https://my-domain.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, when you run the frontend, it will use that URL as the base URL for the API.
|
Then, when you run the frontend, it will use that URL as the base URL for the API.
|
||||||
@@ -95,6 +93,7 @@ The frontend code is structured as follows:
|
|||||||
The frontend includes initial end-to-end tests using Playwright. To run the tests, you need to have the Docker Compose stack running. Start the stack with the following command:
|
The frontend includes initial end-to-end tests using Playwright. To run the tests, you need to have the Docker Compose stack running. Start the stack with the following command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
docker compose run --rm backend bash scripts/prestart.sh
|
||||||
docker compose up -d --wait backend
|
docker compose up -d --wait backend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+14
-2
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.5.6/schema.json",
|
||||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"preset": "recommended",
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noExplicitAny": "off",
|
"noExplicitAny": "off",
|
||||||
"noArrayIndexKey": "off"
|
"noArrayIndexKey": "off"
|
||||||
@@ -42,5 +42,17 @@
|
|||||||
"parser": {
|
"parser": {
|
||||||
"tailwindDirectives": true
|
"tailwindDirectives": true
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"includes": ["**/*.svg"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"a11y": {
|
||||||
|
"noSvgWithoutTitle": "off"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
location /api {
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
location /docs {
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
location /redoc {
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html index.htm;
|
|
||||||
try_files $uri /index.html =404;
|
|
||||||
}
|
|
||||||
|
|
||||||
include /etc/nginx/extra-conf.d/*.conf;
|
|
||||||
}
|
|
||||||
@@ -5,29 +5,17 @@ export default defineConfig({
|
|||||||
output: "./src/client",
|
output: "./src/client",
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
"legacy/axios",
|
{ name: "@hey-api/client-axios", throwOnError: true },
|
||||||
|
{ name: "@hey-api/typescript", case: "preserve" },
|
||||||
{
|
{
|
||||||
name: "@hey-api/sdk",
|
name: "@hey-api/sdk",
|
||||||
|
operations: {
|
||||||
// NOTE: this doesn't allow tree-shaking
|
// NOTE: this doesn't allow tree-shaking
|
||||||
asClass: true,
|
strategy: "byTags",
|
||||||
operationId: true,
|
methods: "static",
|
||||||
classNameBuilder: "{{name}}Service",
|
containerName: "{{name}}Service",
|
||||||
methodNameBuilder: (operation) => {
|
methodName: (name: string): string => name.replace(/^[^-]*-/, ""),
|
||||||
// @ts-expect-error
|
|
||||||
let name: string = operation.name
|
|
||||||
// @ts-expect-error
|
|
||||||
const service: string = operation.service
|
|
||||||
|
|
||||||
if (service && name.toLowerCase().startsWith(service.toLowerCase())) {
|
|
||||||
name = name.slice(service.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
return name.charAt(0).toLowerCase() + name.slice(1)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "@hey-api/schemas",
|
|
||||||
type: "json",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
+32
-32
@@ -13,54 +13,54 @@
|
|||||||
"test:ui": "bunx playwright test --ui"
|
"test:ui": "bunx playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.7.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.12",
|
"@radix-ui/react-avatar": "^1.2.6",
|
||||||
"@radix-ui/react-checkbox": "^1.3.4",
|
"@radix-ui/react-checkbox": "^1.3.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.16",
|
"@radix-ui/react-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.17",
|
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||||
"@radix-ui/react-label": "^2.1.9",
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
"@radix-ui/react-radio-group": "^1.4.0",
|
"@radix-ui/react-radio-group": "^1.4.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.11",
|
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||||
"@radix-ui/react-select": "^2.3.0",
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
"@radix-ui/react-separator": "^1.1.9",
|
"@radix-ui/react-separator": "^1.1.15",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.14",
|
"@radix-ui/react-tabs": "^1.1.21",
|
||||||
"@radix-ui/react-tooltip": "^1.2.9",
|
"@radix-ui/react-tooltip": "^1.2.16",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"@tanstack/react-query-devtools": "^5.101.0",
|
"@tanstack/react-query-devtools": "^5.101.4",
|
||||||
"@tanstack/react-router": "^1.170.15",
|
"@tanstack/react-router": "^1.170.18",
|
||||||
"@tanstack/react-router-devtools": "^1.167.0",
|
"@tanstack/react-router-devtools": "^1.167.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"axios": "1.16.0",
|
"axios": "1.18.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"form-data": "4.0.6",
|
"form-data": "4.0.6",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.28.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.8",
|
||||||
"react-error-boundary": "^6.1.2",
|
"react-error-boundary": "^6.1.2",
|
||||||
"react-hook-form": "^7.78.0",
|
"react-hook-form": "^7.84.0",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.7.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.16",
|
"@biomejs/biome": "^2.5.6",
|
||||||
"@hey-api/openapi-ts": "0.73.0",
|
"@hey-api/openapi-ts": "0.97.3",
|
||||||
"@playwright/test": "1.61.1",
|
"@playwright/test": "1.62.1",
|
||||||
"@tanstack/router-devtools": "^1.167.0",
|
"@tanstack/router-devtools": "^1.167.0",
|
||||||
"@tanstack/router-plugin": "^1.168.18",
|
"@tanstack/router-plugin": "^1.168.23",
|
||||||
"@types/node": "^25.9.2",
|
"@types/node": "^26.1.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.4",
|
||||||
"@vitejs/plugin-react-swc": "^4.3.1",
|
"@vitejs/plugin-react-swc": "^4.3.3",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
import 'dotenv/config'
|
import 'dotenv/config'
|
||||||
|
|
||||||
|
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read environment variables from file.
|
* Read environment variables from file.
|
||||||
* https://github.com/motdotla/dotenv
|
* https://github.com/motdotla/dotenv
|
||||||
@@ -24,7 +26,7 @@ export default defineConfig({
|
|||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
use: {
|
use: {
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
baseURL: 'http://localhost:5173',
|
baseURL,
|
||||||
|
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
@@ -83,9 +85,11 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run your local dev server before starting the tests */
|
||||||
webServer: {
|
webServer: process.env.PLAYWRIGHT_BASE_URL
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
command: 'bun run dev',
|
command: 'bun run dev',
|
||||||
url: 'http://localhost:5173',
|
url: baseURL,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import { type ClientOptions, type Config, createClient, createConfig } from './client';
|
||||||
|
import type { ClientOptions as ClientOptions2 } from './types.gen';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `createClientConfig()` function will be called on client initialization
|
||||||
|
* and the returned object will become the client's initial configuration.
|
||||||
|
*
|
||||||
|
* You may want to initialize your client this way instead of calling
|
||||||
|
* `setConfig()`. This is useful for example if you're using Next.js
|
||||||
|
* to ensure your client always has the correct values.
|
||||||
|
*/
|
||||||
|
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
|
||||||
|
|
||||||
|
export const client = createClient(createConfig<ClientOptions2>({ throwOnError: true }));
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { AxiosError, AxiosInstance, RawAxiosRequestHeaders } from 'axios';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
import { createSseClient } from '../core/serverSentEvents.gen';
|
||||||
|
import type { HttpMethod } from '../core/types.gen';
|
||||||
|
import { getValidRequestBody } from '../core/utils.gen';
|
||||||
|
import type { Client, Config, RequestOptions } from './types.gen';
|
||||||
|
import { buildUrl, createConfig, mergeConfigs, mergeHeaders, setAuthParams } from './utils.gen';
|
||||||
|
|
||||||
|
export const createClient = (config: Config = {}): Client => {
|
||||||
|
let _config = mergeConfigs(createConfig(), config);
|
||||||
|
|
||||||
|
let instance: AxiosInstance;
|
||||||
|
|
||||||
|
if (_config.axios && !('Axios' in _config.axios)) {
|
||||||
|
instance = _config.axios;
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { auth, ...configWithoutAuth } = _config;
|
||||||
|
instance = axios.create(configWithoutAuth);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConfig = (): Config => ({ ..._config });
|
||||||
|
|
||||||
|
const setConfig = (config: Config): Config => {
|
||||||
|
_config = mergeConfigs(_config, config);
|
||||||
|
instance.defaults = {
|
||||||
|
...instance.defaults,
|
||||||
|
..._config,
|
||||||
|
// @ts-expect-error
|
||||||
|
headers: mergeHeaders(instance.defaults.headers, _config.headers),
|
||||||
|
};
|
||||||
|
return getConfig();
|
||||||
|
};
|
||||||
|
|
||||||
|
const beforeRequest = async <
|
||||||
|
TData = unknown,
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
Url extends string = string,
|
||||||
|
>(
|
||||||
|
options: RequestOptions<TData, ThrowOnError, Url>,
|
||||||
|
) => {
|
||||||
|
const opts = {
|
||||||
|
..._config,
|
||||||
|
...options,
|
||||||
|
axios: options.axios ?? _config.axios ?? instance,
|
||||||
|
headers: mergeHeaders(_config.headers, options.headers),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (opts.security) {
|
||||||
|
await setAuthParams(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.requestValidator) {
|
||||||
|
await opts.requestValidator(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.body !== undefined && opts.bodySerializer) {
|
||||||
|
opts.body = opts.bodySerializer(opts.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = buildUrl(opts);
|
||||||
|
|
||||||
|
return { opts, url };
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
const request: Client['request'] = async (options) => {
|
||||||
|
const { opts, url } = await beforeRequest(options);
|
||||||
|
try {
|
||||||
|
// assign Axios here for consistency with fetch
|
||||||
|
const _axios = opts.axios!;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { auth, ...optsWithoutAuth } = opts;
|
||||||
|
const response = await _axios({
|
||||||
|
...optsWithoutAuth,
|
||||||
|
baseURL: '', // the baseURL is already included in `url`
|
||||||
|
data: getValidRequestBody(opts),
|
||||||
|
headers: opts.headers as RawAxiosRequestHeaders,
|
||||||
|
// let `paramsSerializer()` handle query params if it exists
|
||||||
|
params: opts.paramsSerializer ? opts.query : undefined,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
|
||||||
|
let { data } = response;
|
||||||
|
|
||||||
|
if (opts.responseType === 'json') {
|
||||||
|
if (opts.responseValidator) {
|
||||||
|
await opts.responseValidator(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.responseTransformer) {
|
||||||
|
data = await opts.responseTransformer(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
data: data ?? {},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const e = error as AxiosError;
|
||||||
|
if (opts.throwOnError) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
// @ts-expect-error
|
||||||
|
e.error = e.response?.data ?? {};
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
|
||||||
|
request({ ...options, method });
|
||||||
|
|
||||||
|
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
||||||
|
const { opts, url } = await beforeRequest(options);
|
||||||
|
return createSseClient({
|
||||||
|
...opts,
|
||||||
|
body: opts.body as BodyInit | null | undefined,
|
||||||
|
headers: opts.headers as Record<string, string>,
|
||||||
|
method,
|
||||||
|
serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
|
||||||
|
// @ts-expect-error
|
||||||
|
signal: opts.signal,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const _buildUrl: Client['buildUrl'] = (options) =>
|
||||||
|
buildUrl({ axios: instance, ..._config, ...options });
|
||||||
|
|
||||||
|
return {
|
||||||
|
buildUrl: _buildUrl,
|
||||||
|
connect: makeMethodFn('CONNECT'),
|
||||||
|
delete: makeMethodFn('DELETE'),
|
||||||
|
get: makeMethodFn('GET'),
|
||||||
|
getConfig,
|
||||||
|
head: makeMethodFn('HEAD'),
|
||||||
|
instance,
|
||||||
|
options: makeMethodFn('OPTIONS'),
|
||||||
|
patch: makeMethodFn('PATCH'),
|
||||||
|
post: makeMethodFn('POST'),
|
||||||
|
put: makeMethodFn('PUT'),
|
||||||
|
request,
|
||||||
|
setConfig,
|
||||||
|
sse: {
|
||||||
|
connect: makeSseFn('CONNECT'),
|
||||||
|
delete: makeSseFn('DELETE'),
|
||||||
|
get: makeSseFn('GET'),
|
||||||
|
head: makeSseFn('HEAD'),
|
||||||
|
options: makeSseFn('OPTIONS'),
|
||||||
|
patch: makeSseFn('PATCH'),
|
||||||
|
post: makeSseFn('POST'),
|
||||||
|
put: makeSseFn('PUT'),
|
||||||
|
trace: makeSseFn('TRACE'),
|
||||||
|
},
|
||||||
|
trace: makeMethodFn('TRACE'),
|
||||||
|
} as Client;
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
export type { Auth } from '../core/auth.gen';
|
||||||
|
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||||
|
export {
|
||||||
|
formDataBodySerializer,
|
||||||
|
jsonBodySerializer,
|
||||||
|
urlSearchParamsBodySerializer,
|
||||||
|
} from '../core/bodySerializer.gen';
|
||||||
|
export { buildClientParams } from '../core/params.gen';
|
||||||
|
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
|
||||||
|
export { createClient } from './client.gen';
|
||||||
|
export type {
|
||||||
|
Client,
|
||||||
|
ClientOptions,
|
||||||
|
Config,
|
||||||
|
CreateClientConfig,
|
||||||
|
Options,
|
||||||
|
RequestOptions,
|
||||||
|
RequestResult,
|
||||||
|
TDataShape,
|
||||||
|
} from './types.gen';
|
||||||
|
export { createConfig } from './utils.gen';
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AxiosError,
|
||||||
|
AxiosInstance,
|
||||||
|
AxiosRequestHeaders,
|
||||||
|
AxiosResponse,
|
||||||
|
AxiosStatic,
|
||||||
|
CreateAxiosDefaults,
|
||||||
|
} from 'axios';
|
||||||
|
|
||||||
|
import type { Auth } from '../core/auth.gen';
|
||||||
|
import type {
|
||||||
|
ServerSentEventsOptions,
|
||||||
|
ServerSentEventsResult,
|
||||||
|
} from '../core/serverSentEvents.gen';
|
||||||
|
import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen';
|
||||||
|
|
||||||
|
export interface Config<T extends ClientOptions = ClientOptions>
|
||||||
|
extends Omit<CreateAxiosDefaults, 'auth' | 'baseURL' | 'headers' | 'method'>, CoreConfig {
|
||||||
|
/**
|
||||||
|
* Axios implementation. You can use this option to provide either an
|
||||||
|
* `AxiosStatic` or an `AxiosInstance`.
|
||||||
|
*
|
||||||
|
* @default axios
|
||||||
|
*/
|
||||||
|
axios?: AxiosStatic | AxiosInstance;
|
||||||
|
/**
|
||||||
|
* Base URL for all requests made by this client.
|
||||||
|
*/
|
||||||
|
baseURL?: T['baseURL'];
|
||||||
|
/**
|
||||||
|
* An object containing any HTTP headers that you want to pre-populate your
|
||||||
|
* `Headers` object with.
|
||||||
|
*
|
||||||
|
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
||||||
|
*/
|
||||||
|
headers?:
|
||||||
|
| AxiosRequestHeaders
|
||||||
|
| Record<
|
||||||
|
string,
|
||||||
|
string | number | boolean | (string | number | boolean)[] | null | undefined | unknown
|
||||||
|
>;
|
||||||
|
/**
|
||||||
|
* Throw an error instead of returning it in the response?
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
throwOnError?: T['throwOnError'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestOptions<
|
||||||
|
TData = unknown,
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
Url extends string = string,
|
||||||
|
>
|
||||||
|
extends
|
||||||
|
Config<{
|
||||||
|
throwOnError: ThrowOnError;
|
||||||
|
}>,
|
||||||
|
Pick<
|
||||||
|
ServerSentEventsOptions<TData>,
|
||||||
|
| 'onRequest'
|
||||||
|
| 'onSseError'
|
||||||
|
| 'onSseEvent'
|
||||||
|
| 'sseDefaultRetryDelay'
|
||||||
|
| 'sseMaxRetryAttempts'
|
||||||
|
| 'sseMaxRetryDelay'
|
||||||
|
> {
|
||||||
|
/**
|
||||||
|
* Any body that you want to add to your request.
|
||||||
|
*
|
||||||
|
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
|
||||||
|
*/
|
||||||
|
body?: unknown;
|
||||||
|
path?: Record<string, unknown>;
|
||||||
|
query?: Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* Security mechanism(s) to use for the request.
|
||||||
|
*/
|
||||||
|
security?: ReadonlyArray<Auth>;
|
||||||
|
url: Url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientOptions {
|
||||||
|
baseURL?: string;
|
||||||
|
throwOnError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestResult<
|
||||||
|
TData = unknown,
|
||||||
|
TError = unknown,
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
> = ThrowOnError extends true
|
||||||
|
? Promise<AxiosResponse<TData extends Record<string, unknown> ? TData[keyof TData] : TData>>
|
||||||
|
: Promise<
|
||||||
|
| (AxiosResponse<TData extends Record<string, unknown> ? TData[keyof TData] : TData> & {
|
||||||
|
error: undefined;
|
||||||
|
})
|
||||||
|
| (AxiosError<TError extends Record<string, unknown> ? TError[keyof TError] : TError> & {
|
||||||
|
data: undefined;
|
||||||
|
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
|
||||||
|
})
|
||||||
|
>;
|
||||||
|
|
||||||
|
type MethodFn = <TData = unknown, TError = unknown, ThrowOnError extends boolean = false>(
|
||||||
|
options: Omit<RequestOptions<TData, ThrowOnError>, 'method'>,
|
||||||
|
) => RequestResult<TData, TError, ThrowOnError>;
|
||||||
|
|
||||||
|
type SseFn = <
|
||||||
|
TData = unknown,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
_TError = unknown,
|
||||||
|
ThrowOnError extends boolean = false,
|
||||||
|
>(
|
||||||
|
options: Omit<RequestOptions<never, ThrowOnError>, 'method'>,
|
||||||
|
) => Promise<ServerSentEventsResult<TData>>;
|
||||||
|
|
||||||
|
type RequestFn = <TData = unknown, TError = unknown, ThrowOnError extends boolean = false>(
|
||||||
|
options: Omit<RequestOptions<TData, ThrowOnError>, 'method'> &
|
||||||
|
Pick<Required<RequestOptions<TData, ThrowOnError>>, 'method'>,
|
||||||
|
) => RequestResult<TData, TError, ThrowOnError>;
|
||||||
|
|
||||||
|
type BuildUrlFn = <
|
||||||
|
TData extends {
|
||||||
|
path?: Record<string, unknown>;
|
||||||
|
query?: Record<string, unknown>;
|
||||||
|
url: string;
|
||||||
|
},
|
||||||
|
>(
|
||||||
|
options: TData &
|
||||||
|
Pick<
|
||||||
|
RequestOptions<unknown, boolean>,
|
||||||
|
'axios' | 'baseURL' | 'paramsSerializer' | 'querySerializer'
|
||||||
|
>,
|
||||||
|
) => string;
|
||||||
|
|
||||||
|
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
|
||||||
|
instance: AxiosInstance;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `createClientConfig()` function will be called on client initialization
|
||||||
|
* and the returned object will become the client's initial configuration.
|
||||||
|
*
|
||||||
|
* You may want to initialize your client this way instead of calling
|
||||||
|
* `setConfig()`. This is useful for example if you're using Next.js
|
||||||
|
* to ensure your client always has the correct values.
|
||||||
|
*/
|
||||||
|
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
|
||||||
|
override?: Config<ClientOptions & T>,
|
||||||
|
) => Config<Required<ClientOptions> & T>;
|
||||||
|
|
||||||
|
export interface TDataShape {
|
||||||
|
body?: unknown;
|
||||||
|
headers?: unknown;
|
||||||
|
path?: unknown;
|
||||||
|
query?: unknown;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||||
|
|
||||||
|
export type Options<
|
||||||
|
TData extends TDataShape = TDataShape,
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
TResponse = unknown,
|
||||||
|
> = OmitKeys<RequestOptions<TResponse, ThrowOnError>, 'body' | 'path' | 'query' | 'url'> &
|
||||||
|
([TData] extends [never] ? unknown : Omit<TData, 'url'>);
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import { getAuthToken } from '../core/auth.gen';
|
||||||
|
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||||
|
import {
|
||||||
|
serializeArrayParam,
|
||||||
|
serializeObjectParam,
|
||||||
|
serializePrimitiveParam,
|
||||||
|
} from '../core/pathSerializer.gen';
|
||||||
|
import { getUrl } from '../core/utils.gen';
|
||||||
|
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
|
||||||
|
|
||||||
|
export const createQuerySerializer = <T = unknown>({
|
||||||
|
parameters = {},
|
||||||
|
...args
|
||||||
|
}: QuerySerializerOptions = {}) => {
|
||||||
|
const querySerializer = (queryParams: T) => {
|
||||||
|
const search: string[] = [];
|
||||||
|
if (queryParams && typeof queryParams === 'object') {
|
||||||
|
for (const name in queryParams) {
|
||||||
|
const value = queryParams[name];
|
||||||
|
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = parameters[name] || args;
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const serializedArray = serializeArrayParam({
|
||||||
|
allowReserved: options.allowReserved,
|
||||||
|
explode: true,
|
||||||
|
name,
|
||||||
|
style: 'form',
|
||||||
|
value,
|
||||||
|
...options.array,
|
||||||
|
});
|
||||||
|
if (serializedArray) search.push(serializedArray);
|
||||||
|
} else if (typeof value === 'object') {
|
||||||
|
const serializedObject = serializeObjectParam({
|
||||||
|
allowReserved: options.allowReserved,
|
||||||
|
explode: true,
|
||||||
|
name,
|
||||||
|
style: 'deepObject',
|
||||||
|
value: value as Record<string, unknown>,
|
||||||
|
...options.object,
|
||||||
|
});
|
||||||
|
if (serializedObject) search.push(serializedObject);
|
||||||
|
} else {
|
||||||
|
const serializedPrimitive = serializePrimitiveParam({
|
||||||
|
allowReserved: options.allowReserved,
|
||||||
|
name,
|
||||||
|
value: value as string,
|
||||||
|
});
|
||||||
|
if (serializedPrimitive) search.push(serializedPrimitive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return search.join('&');
|
||||||
|
};
|
||||||
|
return querySerializer;
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkForExistence = (
|
||||||
|
options: Pick<RequestOptions, 'auth' | 'query'> & {
|
||||||
|
headers: Record<any, unknown>;
|
||||||
|
},
|
||||||
|
name?: string,
|
||||||
|
): boolean => {
|
||||||
|
if (!name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (name in options.headers || options.query?.[name]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
'Cookie' in options.headers &&
|
||||||
|
options.headers['Cookie'] &&
|
||||||
|
typeof options.headers['Cookie'] === 'string'
|
||||||
|
) {
|
||||||
|
return options.headers['Cookie'].includes(`${name}=`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function setAuthParams(
|
||||||
|
options: Pick<RequestOptions, 'auth' | 'query' | 'security'> & {
|
||||||
|
headers: Record<any, unknown>;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
for (const auth of options.security ?? []) {
|
||||||
|
if (checkForExistence(options, auth.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const token = await getAuthToken(auth, options.auth);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = auth.name ?? 'Authorization';
|
||||||
|
|
||||||
|
switch (auth.in) {
|
||||||
|
case 'query':
|
||||||
|
if (!options.query) {
|
||||||
|
options.query = {};
|
||||||
|
}
|
||||||
|
options.query[name] = token;
|
||||||
|
break;
|
||||||
|
case 'cookie': {
|
||||||
|
const value = `${name}=${token}`;
|
||||||
|
if ('Cookie' in options.headers && options.headers['Cookie']) {
|
||||||
|
options.headers['Cookie'] = `${options.headers['Cookie']}; ${value}`;
|
||||||
|
} else {
|
||||||
|
options.headers['Cookie'] = value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'header':
|
||||||
|
default:
|
||||||
|
options.headers[name] = token;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildUrl: Client['buildUrl'] = (options) => {
|
||||||
|
const instanceBaseUrl = options.axios?.defaults?.baseURL;
|
||||||
|
|
||||||
|
const baseUrl =
|
||||||
|
options.baseURL && typeof options.baseURL === 'string' ? options.baseURL : instanceBaseUrl;
|
||||||
|
|
||||||
|
return getUrl({
|
||||||
|
baseUrl: baseUrl as string,
|
||||||
|
path: options.path,
|
||||||
|
// let `paramsSerializer()` handle query params if it exists
|
||||||
|
query: !options.paramsSerializer ? options.query : undefined,
|
||||||
|
querySerializer:
|
||||||
|
typeof options.querySerializer === 'function'
|
||||||
|
? options.querySerializer
|
||||||
|
: createQuerySerializer(options.querySerializer),
|
||||||
|
url: options.url,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mergeConfigs = (a: Config, b: Config): Config => {
|
||||||
|
const config = { ...a, ...b };
|
||||||
|
config.headers = mergeHeaders(a.headers, b.headers);
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Special Axios headers keywords allowing to set headers by request method.
|
||||||
|
*/
|
||||||
|
export const axiosHeadersKeywords = [
|
||||||
|
'common',
|
||||||
|
'delete',
|
||||||
|
'get',
|
||||||
|
'head',
|
||||||
|
'patch',
|
||||||
|
'post',
|
||||||
|
'put',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const mergeHeaders = (
|
||||||
|
...headers: Array<Required<Config>['headers'] | undefined>
|
||||||
|
): Record<any, unknown> => {
|
||||||
|
const mergedHeaders: Record<any, unknown> = {};
|
||||||
|
for (const header of headers) {
|
||||||
|
if (!header || typeof header !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iterator = Object.entries(header);
|
||||||
|
|
||||||
|
for (const [key, value] of iterator) {
|
||||||
|
if (
|
||||||
|
axiosHeadersKeywords.includes(key as (typeof axiosHeadersKeywords)[number]) &&
|
||||||
|
typeof value === 'object'
|
||||||
|
) {
|
||||||
|
mergedHeaders[key] = {
|
||||||
|
...(mergedHeaders[key] as Record<any, unknown>),
|
||||||
|
...value,
|
||||||
|
};
|
||||||
|
} else if (value === null) {
|
||||||
|
delete mergedHeaders[key];
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
for (const v of value) {
|
||||||
|
// @ts-expect-error
|
||||||
|
mergedHeaders[key] = [...(mergedHeaders[key] ?? []), v as string];
|
||||||
|
}
|
||||||
|
} else if (value !== undefined) {
|
||||||
|
// assume object headers are meant to be JSON stringified, i.e., their
|
||||||
|
// content value in OpenAPI specification is 'application/json'
|
||||||
|
mergedHeaders[key] = typeof value === 'object' ? JSON.stringify(value) : (value as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mergedHeaders;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createConfig = <T extends ClientOptions = ClientOptions>(
|
||||||
|
override: Config<Omit<ClientOptions, keyof T> & T> = {},
|
||||||
|
): Config<Omit<ClientOptions, keyof T> & T> => ({
|
||||||
|
...override,
|
||||||
|
});
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import type { ApiRequestOptions } from './ApiRequestOptions';
|
|
||||||
import type { ApiResult } from './ApiResult';
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
|
||||||
public readonly url: string;
|
|
||||||
public readonly status: number;
|
|
||||||
public readonly statusText: string;
|
|
||||||
public readonly body: unknown;
|
|
||||||
public readonly request: ApiRequestOptions;
|
|
||||||
|
|
||||||
constructor(request: ApiRequestOptions, response: ApiResult, message: string) {
|
|
||||||
super(message);
|
|
||||||
|
|
||||||
this.name = 'ApiError';
|
|
||||||
this.url = response.url;
|
|
||||||
this.status = response.status;
|
|
||||||
this.statusText = response.statusText;
|
|
||||||
this.body = response.body;
|
|
||||||
this.request = request;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
export type ApiRequestOptions<T = unknown> = {
|
|
||||||
readonly body?: any;
|
|
||||||
readonly cookies?: Record<string, unknown>;
|
|
||||||
readonly errors?: Record<number | string, string>;
|
|
||||||
readonly formData?: Record<string, unknown> | any[] | Blob | File;
|
|
||||||
readonly headers?: Record<string, unknown>;
|
|
||||||
readonly mediaType?: string;
|
|
||||||
readonly method:
|
|
||||||
| 'DELETE'
|
|
||||||
| 'GET'
|
|
||||||
| 'HEAD'
|
|
||||||
| 'OPTIONS'
|
|
||||||
| 'PATCH'
|
|
||||||
| 'POST'
|
|
||||||
| 'PUT';
|
|
||||||
readonly path?: Record<string, unknown>;
|
|
||||||
readonly query?: Record<string, unknown>;
|
|
||||||
readonly responseHeader?: string;
|
|
||||||
readonly responseTransformer?: (data: unknown) => Promise<T>;
|
|
||||||
readonly url: string;
|
|
||||||
};
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export type ApiResult<TData = any> = {
|
|
||||||
readonly body: TData;
|
|
||||||
readonly ok: boolean;
|
|
||||||
readonly status: number;
|
|
||||||
readonly statusText: string;
|
|
||||||
readonly url: string;
|
|
||||||
};
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
export class CancelError extends Error {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'CancelError';
|
|
||||||
}
|
|
||||||
|
|
||||||
public get isCancelled(): boolean {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OnCancel {
|
|
||||||
readonly isResolved: boolean;
|
|
||||||
readonly isRejected: boolean;
|
|
||||||
readonly isCancelled: boolean;
|
|
||||||
|
|
||||||
(cancelHandler: () => void): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CancelablePromise<T> implements Promise<T> {
|
|
||||||
private _isResolved: boolean;
|
|
||||||
private _isRejected: boolean;
|
|
||||||
private _isCancelled: boolean;
|
|
||||||
readonly cancelHandlers: (() => void)[];
|
|
||||||
readonly promise: Promise<T>;
|
|
||||||
private _resolve?: (value: T | PromiseLike<T>) => void;
|
|
||||||
private _reject?: (reason?: unknown) => void;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
executor: (
|
|
||||||
resolve: (value: T | PromiseLike<T>) => void,
|
|
||||||
reject: (reason?: unknown) => void,
|
|
||||||
onCancel: OnCancel
|
|
||||||
) => void
|
|
||||||
) {
|
|
||||||
this._isResolved = false;
|
|
||||||
this._isRejected = false;
|
|
||||||
this._isCancelled = false;
|
|
||||||
this.cancelHandlers = [];
|
|
||||||
this.promise = new Promise<T>((resolve, reject) => {
|
|
||||||
this._resolve = resolve;
|
|
||||||
this._reject = reject;
|
|
||||||
|
|
||||||
const onResolve = (value: T | PromiseLike<T>): void => {
|
|
||||||
if (this._isResolved || this._isRejected || this._isCancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._isResolved = true;
|
|
||||||
if (this._resolve) this._resolve(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onReject = (reason?: unknown): void => {
|
|
||||||
if (this._isResolved || this._isRejected || this._isCancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._isRejected = true;
|
|
||||||
if (this._reject) this._reject(reason);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCancel = (cancelHandler: () => void): void => {
|
|
||||||
if (this._isResolved || this._isRejected || this._isCancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.cancelHandlers.push(cancelHandler);
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.defineProperty(onCancel, 'isResolved', {
|
|
||||||
get: (): boolean => this._isResolved,
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(onCancel, 'isRejected', {
|
|
||||||
get: (): boolean => this._isRejected,
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(onCancel, 'isCancelled', {
|
|
||||||
get: (): boolean => this._isCancelled,
|
|
||||||
});
|
|
||||||
|
|
||||||
return executor(onResolve, onReject, onCancel as OnCancel);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get [Symbol.toStringTag]() {
|
|
||||||
return "Cancellable Promise";
|
|
||||||
}
|
|
||||||
|
|
||||||
public then<TResult1 = T, TResult2 = never>(
|
|
||||||
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
|
|
||||||
onRejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null
|
|
||||||
): Promise<TResult1 | TResult2> {
|
|
||||||
return this.promise.then(onFulfilled, onRejected);
|
|
||||||
}
|
|
||||||
|
|
||||||
public catch<TResult = never>(
|
|
||||||
onRejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null
|
|
||||||
): Promise<T | TResult> {
|
|
||||||
return this.promise.catch(onRejected);
|
|
||||||
}
|
|
||||||
|
|
||||||
public finally(onFinally?: (() => void) | null): Promise<T> {
|
|
||||||
return this.promise.finally(onFinally);
|
|
||||||
}
|
|
||||||
|
|
||||||
public cancel(): void {
|
|
||||||
if (this._isResolved || this._isRejected || this._isCancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._isCancelled = true;
|
|
||||||
if (this.cancelHandlers.length) {
|
|
||||||
try {
|
|
||||||
for (const cancelHandler of this.cancelHandlers) {
|
|
||||||
cancelHandler();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Cancellation threw an error', error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.cancelHandlers.length = 0;
|
|
||||||
if (this._reject) this._reject(new CancelError('Request aborted'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public get isCancelled(): boolean {
|
|
||||||
return this._isCancelled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
|
|
||||||
import type { ApiRequestOptions } from './ApiRequestOptions';
|
|
||||||
|
|
||||||
type Headers = Record<string, string>;
|
|
||||||
type Middleware<T> = (value: T) => T | Promise<T>;
|
|
||||||
type Resolver<T> = (options: ApiRequestOptions<T>) => Promise<T>;
|
|
||||||
|
|
||||||
export class Interceptors<T> {
|
|
||||||
_fns: Middleware<T>[];
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this._fns = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
eject(fn: Middleware<T>): void {
|
|
||||||
const index = this._fns.indexOf(fn);
|
|
||||||
if (index !== -1) {
|
|
||||||
this._fns = [...this._fns.slice(0, index), ...this._fns.slice(index + 1)];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
use(fn: Middleware<T>): void {
|
|
||||||
this._fns = [...this._fns, fn];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OpenAPIConfig = {
|
|
||||||
BASE: string;
|
|
||||||
CREDENTIALS: 'include' | 'omit' | 'same-origin';
|
|
||||||
ENCODE_PATH?: ((path: string) => string) | undefined;
|
|
||||||
HEADERS?: Headers | Resolver<Headers> | undefined;
|
|
||||||
PASSWORD?: string | Resolver<string> | undefined;
|
|
||||||
TOKEN?: string | Resolver<string> | undefined;
|
|
||||||
USERNAME?: string | Resolver<string> | undefined;
|
|
||||||
VERSION: string;
|
|
||||||
WITH_CREDENTIALS: boolean;
|
|
||||||
interceptors: {
|
|
||||||
request: Interceptors<AxiosRequestConfig>;
|
|
||||||
response: Interceptors<AxiosResponse>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OpenAPI: OpenAPIConfig = {
|
|
||||||
BASE: '',
|
|
||||||
CREDENTIALS: 'include',
|
|
||||||
ENCODE_PATH: undefined,
|
|
||||||
HEADERS: undefined,
|
|
||||||
PASSWORD: undefined,
|
|
||||||
TOKEN: undefined,
|
|
||||||
USERNAME: undefined,
|
|
||||||
VERSION: '0.1.0',
|
|
||||||
WITH_CREDENTIALS: false,
|
|
||||||
interceptors: {
|
|
||||||
request: new Interceptors(),
|
|
||||||
response: new Interceptors(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
export type AuthToken = string | undefined;
|
||||||
|
|
||||||
|
export interface Auth {
|
||||||
|
/**
|
||||||
|
* Which part of the request do we use to send the auth?
|
||||||
|
*
|
||||||
|
* @default 'header'
|
||||||
|
*/
|
||||||
|
in?: 'header' | 'query' | 'cookie';
|
||||||
|
/**
|
||||||
|
* Header or query parameter name.
|
||||||
|
*
|
||||||
|
* @default 'Authorization'
|
||||||
|
*/
|
||||||
|
name?: string;
|
||||||
|
scheme?: 'basic' | 'bearer';
|
||||||
|
type: 'apiKey' | 'http';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAuthToken = async (
|
||||||
|
auth: Auth,
|
||||||
|
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
const token = typeof callback === 'function' ? await callback(auth) : callback;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.scheme === 'bearer') {
|
||||||
|
return `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.scheme === 'basic') {
|
||||||
|
return `Basic ${btoa(token)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen';
|
||||||
|
|
||||||
|
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
export type BodySerializer = (body: unknown) => unknown;
|
||||||
|
|
||||||
|
type QuerySerializerOptionsObject = {
|
||||||
|
allowReserved?: boolean;
|
||||||
|
array?: Partial<SerializerOptions<ArrayStyle>>;
|
||||||
|
object?: Partial<SerializerOptions<ObjectStyle>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
|
||||||
|
/**
|
||||||
|
* Per-parameter serialization overrides. When provided, these settings
|
||||||
|
* override the global array/object settings for specific parameter names.
|
||||||
|
*/
|
||||||
|
parameters?: Record<string, QuerySerializerOptionsObject>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
|
||||||
|
if (typeof value === 'string' || value instanceof Blob) {
|
||||||
|
data.append(key, value);
|
||||||
|
} else if (value instanceof Date) {
|
||||||
|
data.append(key, value.toISOString());
|
||||||
|
} else {
|
||||||
|
data.append(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
data.append(key, value);
|
||||||
|
} else {
|
||||||
|
data.append(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formDataBodySerializer = {
|
||||||
|
bodySerializer: (body: unknown): FormData => {
|
||||||
|
const data = new FormData();
|
||||||
|
|
||||||
|
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((v) => serializeFormDataPair(data, key, v));
|
||||||
|
} else {
|
||||||
|
serializeFormDataPair(data, key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const jsonBodySerializer = {
|
||||||
|
bodySerializer: (body: unknown): string =>
|
||||||
|
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const urlSearchParamsBodySerializer = {
|
||||||
|
bodySerializer: (body: unknown): string => {
|
||||||
|
const data = new URLSearchParams();
|
||||||
|
|
||||||
|
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
|
||||||
|
} else {
|
||||||
|
serializeUrlSearchParamsPair(data, key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.toString();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
type Slot = 'body' | 'headers' | 'path' | 'query';
|
||||||
|
|
||||||
|
export type Field =
|
||||||
|
| {
|
||||||
|
in: Exclude<Slot, 'body'>;
|
||||||
|
/**
|
||||||
|
* Field name. This is the name we want the user to see and use.
|
||||||
|
*/
|
||||||
|
key: string;
|
||||||
|
/**
|
||||||
|
* Field mapped name. This is the name we want to use in the request.
|
||||||
|
* If omitted, we use the same value as `key`.
|
||||||
|
*/
|
||||||
|
map?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
in: Extract<Slot, 'body'>;
|
||||||
|
/**
|
||||||
|
* Key isn't required for bodies.
|
||||||
|
*/
|
||||||
|
key?: string;
|
||||||
|
map?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* Field name. This is the name we want the user to see and use.
|
||||||
|
*/
|
||||||
|
key: string;
|
||||||
|
/**
|
||||||
|
* Field mapped name. This is the name we want to use in the request.
|
||||||
|
* If `in` is omitted, `map` aliases `key` to the transport layer.
|
||||||
|
*/
|
||||||
|
map: Slot;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Fields {
|
||||||
|
allowExtra?: Partial<Record<Slot, boolean>>;
|
||||||
|
args?: ReadonlyArray<Field>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FieldsConfig = ReadonlyArray<Field | Fields>;
|
||||||
|
|
||||||
|
const extraPrefixesMap: Record<string, Slot> = {
|
||||||
|
$body_: 'body',
|
||||||
|
$headers_: 'headers',
|
||||||
|
$path_: 'path',
|
||||||
|
$query_: 'query',
|
||||||
|
};
|
||||||
|
const extraPrefixes = Object.entries(extraPrefixesMap);
|
||||||
|
|
||||||
|
type KeyMap = Map<
|
||||||
|
string,
|
||||||
|
| {
|
||||||
|
in: Slot;
|
||||||
|
map?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
in?: never;
|
||||||
|
map: Slot;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
||||||
|
if (!map) {
|
||||||
|
map = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const config of fields) {
|
||||||
|
if ('in' in config) {
|
||||||
|
if (config.key) {
|
||||||
|
map.set(config.key, {
|
||||||
|
in: config.in,
|
||||||
|
map: config.map,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if ('key' in config) {
|
||||||
|
map.set(config.key, {
|
||||||
|
map: config.map,
|
||||||
|
});
|
||||||
|
} else if (config.args) {
|
||||||
|
buildKeyMap(config.args, map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Params {
|
||||||
|
body: unknown;
|
||||||
|
headers: Record<string, unknown>;
|
||||||
|
path: Record<string, unknown>;
|
||||||
|
query: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripEmptySlots = (params: Params) => {
|
||||||
|
for (const [slot, value] of Object.entries(params)) {
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) {
|
||||||
|
delete params[slot as Slot];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
|
||||||
|
const params: Params = {
|
||||||
|
body: Object.create(null),
|
||||||
|
headers: Object.create(null),
|
||||||
|
path: Object.create(null),
|
||||||
|
query: Object.create(null),
|
||||||
|
};
|
||||||
|
|
||||||
|
const map = buildKeyMap(fields);
|
||||||
|
|
||||||
|
let config: FieldsConfig[number] | undefined;
|
||||||
|
|
||||||
|
for (const [index, arg] of args.entries()) {
|
||||||
|
if (fields[index]) {
|
||||||
|
config = fields[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('in' in config) {
|
||||||
|
if (config.key) {
|
||||||
|
const field = map.get(config.key)!;
|
||||||
|
const name = field.map || config.key;
|
||||||
|
if (field.in) {
|
||||||
|
(params[field.in] as Record<string, unknown>)[name] = arg;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
params.body = arg;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const [key, value] of Object.entries(arg ?? {})) {
|
||||||
|
const field = map.get(key);
|
||||||
|
|
||||||
|
if (field) {
|
||||||
|
if (field.in) {
|
||||||
|
const name = field.map || key;
|
||||||
|
(params[field.in] as Record<string, unknown>)[name] = value;
|
||||||
|
} else {
|
||||||
|
params[field.map] = value;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
|
||||||
|
|
||||||
|
if (extra) {
|
||||||
|
const [prefix, slot] = extra;
|
||||||
|
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
|
||||||
|
} else if ('allowExtra' in config && config.allowExtra) {
|
||||||
|
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
||||||
|
if (allowed) {
|
||||||
|
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stripEmptySlots(params);
|
||||||
|
|
||||||
|
return params;
|
||||||
|
};
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
|
||||||
|
|
||||||
|
interface SerializePrimitiveOptions {
|
||||||
|
allowReserved?: boolean;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerializerOptions<T> {
|
||||||
|
/**
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
explode: boolean;
|
||||||
|
style: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
|
||||||
|
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
|
||||||
|
type MatrixStyle = 'label' | 'matrix' | 'simple';
|
||||||
|
export type ObjectStyle = 'form' | 'deepObject';
|
||||||
|
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
|
||||||
|
|
||||||
|
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
|
||||||
|
switch (style) {
|
||||||
|
case 'label':
|
||||||
|
return '.';
|
||||||
|
case 'matrix':
|
||||||
|
return ';';
|
||||||
|
case 'simple':
|
||||||
|
return ',';
|
||||||
|
default:
|
||||||
|
return '&';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
|
||||||
|
switch (style) {
|
||||||
|
case 'form':
|
||||||
|
return ',';
|
||||||
|
case 'pipeDelimited':
|
||||||
|
return '|';
|
||||||
|
case 'spaceDelimited':
|
||||||
|
return '%20';
|
||||||
|
default:
|
||||||
|
return ',';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
|
||||||
|
switch (style) {
|
||||||
|
case 'label':
|
||||||
|
return '.';
|
||||||
|
case 'matrix':
|
||||||
|
return ';';
|
||||||
|
case 'simple':
|
||||||
|
return ',';
|
||||||
|
default:
|
||||||
|
return '&';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeArrayParam = ({
|
||||||
|
allowReserved,
|
||||||
|
explode,
|
||||||
|
name,
|
||||||
|
style,
|
||||||
|
value,
|
||||||
|
}: SerializeOptions<ArraySeparatorStyle> & {
|
||||||
|
value: unknown[];
|
||||||
|
}) => {
|
||||||
|
if (!explode) {
|
||||||
|
const joinedValues = (
|
||||||
|
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
|
||||||
|
).join(separatorArrayNoExplode(style));
|
||||||
|
switch (style) {
|
||||||
|
case 'label':
|
||||||
|
return `.${joinedValues}`;
|
||||||
|
case 'matrix':
|
||||||
|
return `;${name}=${joinedValues}`;
|
||||||
|
case 'simple':
|
||||||
|
return joinedValues;
|
||||||
|
default:
|
||||||
|
return `${name}=${joinedValues}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = separatorArrayExplode(style);
|
||||||
|
const joinedValues = value
|
||||||
|
.map((v) => {
|
||||||
|
if (style === 'label' || style === 'simple') {
|
||||||
|
return allowReserved ? v : encodeURIComponent(v as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
return serializePrimitiveParam({
|
||||||
|
allowReserved,
|
||||||
|
name,
|
||||||
|
value: v as string,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.join(separator);
|
||||||
|
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializePrimitiveParam = ({
|
||||||
|
allowReserved,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
}: SerializePrimitiveParam) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
throw new Error(
|
||||||
|
'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeObjectParam = ({
|
||||||
|
allowReserved,
|
||||||
|
explode,
|
||||||
|
name,
|
||||||
|
style,
|
||||||
|
value,
|
||||||
|
valueOnly,
|
||||||
|
}: SerializeOptions<ObjectSeparatorStyle> & {
|
||||||
|
value: Record<string, unknown> | Date;
|
||||||
|
valueOnly?: boolean;
|
||||||
|
}) => {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style !== 'deepObject' && !explode) {
|
||||||
|
let values: string[] = [];
|
||||||
|
Object.entries(value).forEach(([key, v]) => {
|
||||||
|
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)];
|
||||||
|
});
|
||||||
|
const joinedValues = values.join(',');
|
||||||
|
switch (style) {
|
||||||
|
case 'form':
|
||||||
|
return `${name}=${joinedValues}`;
|
||||||
|
case 'label':
|
||||||
|
return `.${joinedValues}`;
|
||||||
|
case 'matrix':
|
||||||
|
return `;${name}=${joinedValues}`;
|
||||||
|
default:
|
||||||
|
return joinedValues;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = separatorObjectExplode(style);
|
||||||
|
const joinedValues = Object.entries(value)
|
||||||
|
.map(([key, v]) =>
|
||||||
|
serializePrimitiveParam({
|
||||||
|
allowReserved,
|
||||||
|
name: style === 'deepObject' ? `${name}[${key}]` : key,
|
||||||
|
value: v as string,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.join(separator);
|
||||||
|
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
|
||||||
|
};
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-friendly union that mirrors what Pinia Colada can hash.
|
||||||
|
*/
|
||||||
|
export type JsonValue =
|
||||||
|
| null
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| JsonValue[]
|
||||||
|
| { [key: string]: JsonValue };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
|
||||||
|
*/
|
||||||
|
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
|
||||||
|
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value === 'bigint') {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely stringifies a value and parses it back into a JsonValue.
|
||||||
|
*/
|
||||||
|
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(input, queryKeyJsonReplacer);
|
||||||
|
if (json === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return JSON.parse(json) as JsonValue;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects plain objects (including objects with a null prototype).
|
||||||
|
*/
|
||||||
|
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||||
|
if (value === null || typeof value !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const prototype = Object.getPrototypeOf(value as object);
|
||||||
|
return prototype === Object.prototype || prototype === null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
|
||||||
|
*/
|
||||||
|
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
|
||||||
|
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
const result: Record<string, JsonValue> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
const existing = result[key];
|
||||||
|
if (existing === undefined) {
|
||||||
|
result[key] = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(existing)) {
|
||||||
|
(existing as string[]).push(value);
|
||||||
|
} else {
|
||||||
|
result[key] = [existing, value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes any accepted value into a JSON-friendly shape for query keys.
|
||||||
|
*/
|
||||||
|
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'bigint') {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return stringifyToJsonValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) {
|
||||||
|
return serializeSearchParams(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(value)) {
|
||||||
|
return stringifyToJsonValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios';
|
|
||||||
|
|
||||||
import { ApiError } from './ApiError';
|
|
||||||
import type { ApiRequestOptions } from './ApiRequestOptions';
|
|
||||||
import type { ApiResult } from './ApiResult';
|
|
||||||
import { CancelablePromise } from './CancelablePromise';
|
|
||||||
import type { OnCancel } from './CancelablePromise';
|
|
||||||
import type { OpenAPIConfig } from './OpenAPI';
|
|
||||||
|
|
||||||
export const isString = (value: unknown): value is string => {
|
|
||||||
return typeof value === 'string';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isStringWithValue = (value: unknown): value is string => {
|
|
||||||
return isString(value) && value !== '';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isBlob = (value: any): value is Blob => {
|
|
||||||
return value instanceof Blob;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isFormData = (value: unknown): value is FormData => {
|
|
||||||
return value instanceof FormData;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isSuccess = (status: number): boolean => {
|
|
||||||
return status >= 200 && status < 300;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const base64 = (str: string): string => {
|
|
||||||
try {
|
|
||||||
return btoa(str);
|
|
||||||
} catch (err) {
|
|
||||||
// @ts-ignore
|
|
||||||
return Buffer.from(str).toString('base64');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getQueryString = (params: Record<string, unknown>): string => {
|
|
||||||
const qs: string[] = [];
|
|
||||||
|
|
||||||
const append = (key: string, value: unknown) => {
|
|
||||||
qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const encodePair = (key: string, value: unknown) => {
|
|
||||||
if (value === undefined || value === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value instanceof Date) {
|
|
||||||
append(key, value.toISOString());
|
|
||||||
} else if (Array.isArray(value)) {
|
|
||||||
value.forEach(v => encodePair(key, v));
|
|
||||||
} else if (typeof value === 'object') {
|
|
||||||
Object.entries(value).forEach(([k, v]) => encodePair(`${key}[${k}]`, v));
|
|
||||||
} else {
|
|
||||||
append(key, value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.entries(params).forEach(([key, value]) => encodePair(key, value));
|
|
||||||
|
|
||||||
return qs.length ? `?${qs.join('&')}` : '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => {
|
|
||||||
const encoder = config.ENCODE_PATH || encodeURI;
|
|
||||||
|
|
||||||
const path = options.url
|
|
||||||
.replace('{api-version}', config.VERSION)
|
|
||||||
.replace(/{(.*?)}/g, (substring: string, group: string) => {
|
|
||||||
if (options.path?.hasOwnProperty(group)) {
|
|
||||||
return encoder(String(options.path[group]));
|
|
||||||
}
|
|
||||||
return substring;
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = config.BASE + path;
|
|
||||||
return options.query ? url + getQueryString(options.query) : url;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getFormData = (options: ApiRequestOptions): FormData | undefined => {
|
|
||||||
if (options.formData) {
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
const process = (key: string, value: unknown) => {
|
|
||||||
if (isString(value) || isBlob(value)) {
|
|
||||||
formData.append(key, value);
|
|
||||||
} else {
|
|
||||||
formData.append(key, JSON.stringify(value));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.entries(options.formData)
|
|
||||||
.filter(([, value]) => value !== undefined && value !== null)
|
|
||||||
.forEach(([key, value]) => {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
value.forEach(v => process(key, v));
|
|
||||||
} else {
|
|
||||||
process(key, value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return formData;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Resolver<T> = (options: ApiRequestOptions<T>) => Promise<T>;
|
|
||||||
|
|
||||||
export const resolve = async <T>(options: ApiRequestOptions<T>, resolver?: T | Resolver<T>): Promise<T | undefined> => {
|
|
||||||
if (typeof resolver === 'function') {
|
|
||||||
return (resolver as Resolver<T>)(options);
|
|
||||||
}
|
|
||||||
return resolver;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getHeaders = async <T>(config: OpenAPIConfig, options: ApiRequestOptions<T>): Promise<Record<string, string>> => {
|
|
||||||
const [token, username, password, additionalHeaders] = await Promise.all([
|
|
||||||
// @ts-ignore
|
|
||||||
resolve(options, config.TOKEN),
|
|
||||||
// @ts-ignore
|
|
||||||
resolve(options, config.USERNAME),
|
|
||||||
// @ts-ignore
|
|
||||||
resolve(options, config.PASSWORD),
|
|
||||||
// @ts-ignore
|
|
||||||
resolve(options, config.HEADERS),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const headers = Object.entries({
|
|
||||||
Accept: 'application/json',
|
|
||||||
...additionalHeaders,
|
|
||||||
...options.headers,
|
|
||||||
})
|
|
||||||
.filter(([, value]) => value !== undefined && value !== null)
|
|
||||||
.reduce((headers, [key, value]) => ({
|
|
||||||
...headers,
|
|
||||||
[key]: String(value),
|
|
||||||
}), {} as Record<string, string>);
|
|
||||||
|
|
||||||
if (isStringWithValue(token)) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isStringWithValue(username) && isStringWithValue(password)) {
|
|
||||||
const credentials = base64(`${username}:${password}`);
|
|
||||||
headers['Authorization'] = `Basic ${credentials}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.body !== undefined) {
|
|
||||||
if (options.mediaType) {
|
|
||||||
headers['Content-Type'] = options.mediaType;
|
|
||||||
} else if (isBlob(options.body)) {
|
|
||||||
headers['Content-Type'] = options.body.type || 'application/octet-stream';
|
|
||||||
} else if (isString(options.body)) {
|
|
||||||
headers['Content-Type'] = 'text/plain';
|
|
||||||
} else if (!isFormData(options.body)) {
|
|
||||||
headers['Content-Type'] = 'application/json';
|
|
||||||
}
|
|
||||||
} else if (options.formData !== undefined) {
|
|
||||||
if (options.mediaType) {
|
|
||||||
headers['Content-Type'] = options.mediaType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getRequestBody = (options: ApiRequestOptions): unknown => {
|
|
||||||
if (options.body) {
|
|
||||||
return options.body;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const sendRequest = async <T>(
|
|
||||||
config: OpenAPIConfig,
|
|
||||||
options: ApiRequestOptions<T>,
|
|
||||||
url: string,
|
|
||||||
body: unknown,
|
|
||||||
formData: FormData | undefined,
|
|
||||||
headers: Record<string, string>,
|
|
||||||
onCancel: OnCancel,
|
|
||||||
axiosClient: AxiosInstance
|
|
||||||
): Promise<AxiosResponse<T>> => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
let requestConfig: AxiosRequestConfig = {
|
|
||||||
data: body ?? formData,
|
|
||||||
headers,
|
|
||||||
method: options.method,
|
|
||||||
signal: controller.signal,
|
|
||||||
url,
|
|
||||||
withCredentials: config.WITH_CREDENTIALS,
|
|
||||||
};
|
|
||||||
|
|
||||||
onCancel(() => controller.abort());
|
|
||||||
|
|
||||||
for (const fn of config.interceptors.request._fns) {
|
|
||||||
requestConfig = await fn(requestConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await axiosClient.request(requestConfig);
|
|
||||||
} catch (error) {
|
|
||||||
const axiosError = error as AxiosError<T>;
|
|
||||||
if (axiosError.response) {
|
|
||||||
return axiosError.response;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getResponseHeader = (response: AxiosResponse<unknown>, responseHeader?: string): string | undefined => {
|
|
||||||
if (responseHeader) {
|
|
||||||
const content = response.headers[responseHeader];
|
|
||||||
if (isString(content)) {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getResponseBody = (response: AxiosResponse<unknown>): unknown => {
|
|
||||||
if (response.status !== 204) {
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => {
|
|
||||||
const errors: Record<number, string> = {
|
|
||||||
400: 'Bad Request',
|
|
||||||
401: 'Unauthorized',
|
|
||||||
402: 'Payment Required',
|
|
||||||
403: 'Forbidden',
|
|
||||||
404: 'Not Found',
|
|
||||||
405: 'Method Not Allowed',
|
|
||||||
406: 'Not Acceptable',
|
|
||||||
407: 'Proxy Authentication Required',
|
|
||||||
408: 'Request Timeout',
|
|
||||||
409: 'Conflict',
|
|
||||||
410: 'Gone',
|
|
||||||
411: 'Length Required',
|
|
||||||
412: 'Precondition Failed',
|
|
||||||
413: 'Payload Too Large',
|
|
||||||
414: 'URI Too Long',
|
|
||||||
415: 'Unsupported Media Type',
|
|
||||||
416: 'Range Not Satisfiable',
|
|
||||||
417: 'Expectation Failed',
|
|
||||||
418: 'Im a teapot',
|
|
||||||
421: 'Misdirected Request',
|
|
||||||
422: 'Unprocessable Content',
|
|
||||||
423: 'Locked',
|
|
||||||
424: 'Failed Dependency',
|
|
||||||
425: 'Too Early',
|
|
||||||
426: 'Upgrade Required',
|
|
||||||
428: 'Precondition Required',
|
|
||||||
429: 'Too Many Requests',
|
|
||||||
431: 'Request Header Fields Too Large',
|
|
||||||
451: 'Unavailable For Legal Reasons',
|
|
||||||
500: 'Internal Server Error',
|
|
||||||
501: 'Not Implemented',
|
|
||||||
502: 'Bad Gateway',
|
|
||||||
503: 'Service Unavailable',
|
|
||||||
504: 'Gateway Timeout',
|
|
||||||
505: 'HTTP Version Not Supported',
|
|
||||||
506: 'Variant Also Negotiates',
|
|
||||||
507: 'Insufficient Storage',
|
|
||||||
508: 'Loop Detected',
|
|
||||||
510: 'Not Extended',
|
|
||||||
511: 'Network Authentication Required',
|
|
||||||
...options.errors,
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = errors[result.status];
|
|
||||||
if (error) {
|
|
||||||
throw new ApiError(options, result, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
const errorStatus = result.status ?? 'unknown';
|
|
||||||
const errorStatusText = result.statusText ?? 'unknown';
|
|
||||||
const errorBody = (() => {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(result.body, null, 2);
|
|
||||||
} catch (e) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
throw new ApiError(options, result,
|
|
||||||
`Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request method
|
|
||||||
* @param config The OpenAPI configuration object
|
|
||||||
* @param options The request options from the service
|
|
||||||
* @param axiosClient The axios client instance to use
|
|
||||||
* @returns CancelablePromise<T>
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
|
||||||
export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions<T>, axiosClient: AxiosInstance = axios): CancelablePromise<T> => {
|
|
||||||
return new CancelablePromise(async (resolve, reject, onCancel) => {
|
|
||||||
try {
|
|
||||||
const url = getUrl(config, options);
|
|
||||||
const formData = getFormData(options);
|
|
||||||
const body = getRequestBody(options);
|
|
||||||
const headers = await getHeaders(config, options);
|
|
||||||
|
|
||||||
if (!onCancel.isCancelled) {
|
|
||||||
let response = await sendRequest<T>(config, options, url, body, formData, headers, onCancel, axiosClient);
|
|
||||||
|
|
||||||
for (const fn of config.interceptors.response._fns) {
|
|
||||||
response = await fn(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseBody = getResponseBody(response);
|
|
||||||
const responseHeader = getResponseHeader(response, options.responseHeader);
|
|
||||||
|
|
||||||
let transformedBody = responseBody;
|
|
||||||
if (options.responseTransformer && isSuccess(response.status)) {
|
|
||||||
transformedBody = await options.responseTransformer(responseBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: ApiResult = {
|
|
||||||
url,
|
|
||||||
ok: isSuccess(response.status),
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
body: responseHeader ?? transformedBody,
|
|
||||||
};
|
|
||||||
|
|
||||||
catchErrorCodes(options, result);
|
|
||||||
|
|
||||||
resolve(result.body);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { Config } from './types.gen';
|
||||||
|
|
||||||
|
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, 'method'> &
|
||||||
|
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
|
||||||
|
/**
|
||||||
|
* Fetch API implementation. You can use this option to provide a custom
|
||||||
|
* fetch instance.
|
||||||
|
*
|
||||||
|
* @default globalThis.fetch
|
||||||
|
*/
|
||||||
|
fetch?: typeof fetch;
|
||||||
|
/**
|
||||||
|
* Implementing clients can call request interceptors inside this hook.
|
||||||
|
*/
|
||||||
|
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
|
||||||
|
/**
|
||||||
|
* Callback invoked when a network or parsing error occurs during streaming.
|
||||||
|
*
|
||||||
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
|
*
|
||||||
|
* @param error The error that occurred.
|
||||||
|
*/
|
||||||
|
onSseError?: (error: unknown) => void;
|
||||||
|
/**
|
||||||
|
* Callback invoked when an event is streamed from the server.
|
||||||
|
*
|
||||||
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
|
*
|
||||||
|
* @param event Event streamed from the server.
|
||||||
|
* @returns Nothing (void).
|
||||||
|
*/
|
||||||
|
onSseEvent?: (event: StreamEvent<TData>) => void;
|
||||||
|
serializedBody?: RequestInit['body'];
|
||||||
|
/**
|
||||||
|
* Default retry delay in milliseconds.
|
||||||
|
*
|
||||||
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
|
*
|
||||||
|
* @default 3000
|
||||||
|
*/
|
||||||
|
sseDefaultRetryDelay?: number;
|
||||||
|
/**
|
||||||
|
* Maximum number of retry attempts before giving up.
|
||||||
|
*/
|
||||||
|
sseMaxRetryAttempts?: number;
|
||||||
|
/**
|
||||||
|
* Maximum retry delay in milliseconds.
|
||||||
|
*
|
||||||
|
* Applies only when exponential backoff is used.
|
||||||
|
*
|
||||||
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
|
*
|
||||||
|
* @default 30000
|
||||||
|
*/
|
||||||
|
sseMaxRetryDelay?: number;
|
||||||
|
/**
|
||||||
|
* Optional sleep function for retry backoff.
|
||||||
|
*
|
||||||
|
* Defaults to using `setTimeout`.
|
||||||
|
*/
|
||||||
|
sseSleepFn?: (ms: number) => Promise<void>;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface StreamEvent<TData = unknown> {
|
||||||
|
data: TData;
|
||||||
|
event?: string;
|
||||||
|
id?: string;
|
||||||
|
retry?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
|
||||||
|
stream: AsyncGenerator<
|
||||||
|
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
|
||||||
|
TReturn,
|
||||||
|
TNext
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createSseClient<TData = unknown>({
|
||||||
|
onRequest,
|
||||||
|
onSseError,
|
||||||
|
onSseEvent,
|
||||||
|
responseTransformer,
|
||||||
|
responseValidator,
|
||||||
|
sseDefaultRetryDelay,
|
||||||
|
sseMaxRetryAttempts,
|
||||||
|
sseMaxRetryDelay,
|
||||||
|
sseSleepFn,
|
||||||
|
url,
|
||||||
|
...options
|
||||||
|
}: ServerSentEventsOptions): ServerSentEventsResult<TData> {
|
||||||
|
let lastEventId: string | undefined;
|
||||||
|
|
||||||
|
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||||
|
|
||||||
|
const createStream = async function* () {
|
||||||
|
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
||||||
|
let attempt = 0;
|
||||||
|
const signal = options.signal ?? new AbortController().signal;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (signal.aborted) break;
|
||||||
|
|
||||||
|
attempt++;
|
||||||
|
|
||||||
|
const headers =
|
||||||
|
options.headers instanceof Headers
|
||||||
|
? options.headers
|
||||||
|
: new Headers(options.headers as Record<string, string> | undefined);
|
||||||
|
|
||||||
|
if (lastEventId !== undefined) {
|
||||||
|
headers.set('Last-Event-ID', lastEventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestInit: RequestInit = {
|
||||||
|
redirect: 'follow',
|
||||||
|
...options,
|
||||||
|
body: options.serializedBody,
|
||||||
|
headers,
|
||||||
|
signal,
|
||||||
|
};
|
||||||
|
let request = new Request(url, requestInit);
|
||||||
|
if (onRequest) {
|
||||||
|
request = await onRequest(url, requestInit);
|
||||||
|
}
|
||||||
|
// fetch must be assigned here, otherwise it would throw the error:
|
||||||
|
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||||
|
const _fetch = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await _fetch(request);
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
|
||||||
|
|
||||||
|
if (!response.body) throw new Error('No body in SSE response');
|
||||||
|
|
||||||
|
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
||||||
|
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
const abortHandler = () => {
|
||||||
|
try {
|
||||||
|
reader.cancel();
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
signal.addEventListener('abort', abortHandler);
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += value;
|
||||||
|
buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings
|
||||||
|
|
||||||
|
const chunks = buffer.split('\n\n');
|
||||||
|
buffer = chunks.pop() ?? '';
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const lines = chunk.split('\n');
|
||||||
|
const dataLines: Array<string> = [];
|
||||||
|
let eventName: string | undefined;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data:')) {
|
||||||
|
dataLines.push(line.replace(/^data:\s*/, ''));
|
||||||
|
} else if (line.startsWith('event:')) {
|
||||||
|
eventName = line.replace(/^event:\s*/, '');
|
||||||
|
} else if (line.startsWith('id:')) {
|
||||||
|
lastEventId = line.replace(/^id:\s*/, '');
|
||||||
|
} else if (line.startsWith('retry:')) {
|
||||||
|
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
retryDelay = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: unknown;
|
||||||
|
let parsedJson = false;
|
||||||
|
|
||||||
|
if (dataLines.length) {
|
||||||
|
const rawData = dataLines.join('\n');
|
||||||
|
try {
|
||||||
|
data = JSON.parse(rawData);
|
||||||
|
parsedJson = true;
|
||||||
|
} catch {
|
||||||
|
data = rawData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedJson) {
|
||||||
|
if (responseValidator) {
|
||||||
|
await responseValidator(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseTransformer) {
|
||||||
|
data = await responseTransformer(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSseEvent?.({
|
||||||
|
data,
|
||||||
|
event: eventName,
|
||||||
|
id: lastEventId,
|
||||||
|
retry: retryDelay,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dataLines.length) {
|
||||||
|
yield data as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
signal.removeEventListener('abort', abortHandler);
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
break; // exit loop on normal completion
|
||||||
|
} catch (error) {
|
||||||
|
// connection failed or aborted; retry after delay
|
||||||
|
onSseError?.(error);
|
||||||
|
|
||||||
|
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
|
||||||
|
break; // stop after firing error
|
||||||
|
}
|
||||||
|
|
||||||
|
// exponential backoff: double retry each attempt, cap at 30s
|
||||||
|
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
|
||||||
|
await sleep(backoff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream = createStream();
|
||||||
|
|
||||||
|
return { stream };
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { Auth, AuthToken } from './auth.gen';
|
||||||
|
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen';
|
||||||
|
|
||||||
|
export type HttpMethod =
|
||||||
|
| 'connect'
|
||||||
|
| 'delete'
|
||||||
|
| 'get'
|
||||||
|
| 'head'
|
||||||
|
| 'options'
|
||||||
|
| 'patch'
|
||||||
|
| 'post'
|
||||||
|
| 'put'
|
||||||
|
| 'trace';
|
||||||
|
|
||||||
|
export type Client<
|
||||||
|
RequestFn = never,
|
||||||
|
Config = unknown,
|
||||||
|
MethodFn = never,
|
||||||
|
BuildUrlFn = never,
|
||||||
|
SseFn = never,
|
||||||
|
> = {
|
||||||
|
/**
|
||||||
|
* Returns the final request URL.
|
||||||
|
*/
|
||||||
|
buildUrl: BuildUrlFn;
|
||||||
|
getConfig: () => Config;
|
||||||
|
request: RequestFn;
|
||||||
|
setConfig: (config: Config) => Config;
|
||||||
|
} & {
|
||||||
|
[K in HttpMethod]: MethodFn;
|
||||||
|
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } });
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
/**
|
||||||
|
* Auth token or a function returning auth token. The resolved value will be
|
||||||
|
* added to the request payload as defined by its `security` array.
|
||||||
|
*/
|
||||||
|
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
|
||||||
|
/**
|
||||||
|
* A function for serializing request body parameter. By default,
|
||||||
|
* {@link JSON.stringify()} will be used.
|
||||||
|
*/
|
||||||
|
bodySerializer?: BodySerializer | null;
|
||||||
|
/**
|
||||||
|
* An object containing any HTTP headers that you want to pre-populate your
|
||||||
|
* `Headers` object with.
|
||||||
|
*
|
||||||
|
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
||||||
|
*/
|
||||||
|
headers?:
|
||||||
|
| RequestInit['headers']
|
||||||
|
| Record<
|
||||||
|
string,
|
||||||
|
string | number | boolean | (string | number | boolean)[] | null | undefined | unknown
|
||||||
|
>;
|
||||||
|
/**
|
||||||
|
* The request method.
|
||||||
|
*
|
||||||
|
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
|
||||||
|
*/
|
||||||
|
method?: Uppercase<HttpMethod>;
|
||||||
|
/**
|
||||||
|
* A function for serializing request query parameters. By default, arrays
|
||||||
|
* will be exploded in form style, objects will be exploded in deepObject
|
||||||
|
* style, and reserved characters are percent-encoded.
|
||||||
|
*
|
||||||
|
* This method will have no effect if the native `paramsSerializer()` Axios
|
||||||
|
* API function is used.
|
||||||
|
*
|
||||||
|
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
|
||||||
|
*/
|
||||||
|
querySerializer?: QuerySerializer | QuerySerializerOptions;
|
||||||
|
/**
|
||||||
|
* A function validating request data. This is useful if you want to ensure
|
||||||
|
* the request conforms to the desired shape, so it can be safely sent to
|
||||||
|
* the server.
|
||||||
|
*/
|
||||||
|
requestValidator?: (data: unknown) => Promise<unknown>;
|
||||||
|
/**
|
||||||
|
* A function transforming response data before it's returned. This is useful
|
||||||
|
* for post-processing data, e.g., converting ISO strings into Date objects.
|
||||||
|
*/
|
||||||
|
responseTransformer?: (data: unknown) => Promise<unknown>;
|
||||||
|
/**
|
||||||
|
* A function validating response data. This is useful if you want to ensure
|
||||||
|
* the response conforms to the desired shape, so it can be safely passed to
|
||||||
|
* the transformers and returned to the user.
|
||||||
|
*/
|
||||||
|
responseValidator?: (data: unknown) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
|
||||||
|
? true
|
||||||
|
: [T] extends [never | undefined]
|
||||||
|
? [undefined] extends [T]
|
||||||
|
? false
|
||||||
|
: true
|
||||||
|
: false;
|
||||||
|
|
||||||
|
export type OmitNever<T extends Record<string, unknown>> = {
|
||||||
|
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K];
|
||||||
|
};
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
|
||||||
|
import {
|
||||||
|
type ArraySeparatorStyle,
|
||||||
|
serializeArrayParam,
|
||||||
|
serializeObjectParam,
|
||||||
|
serializePrimitiveParam,
|
||||||
|
} from './pathSerializer.gen';
|
||||||
|
|
||||||
|
export interface PathSerializer {
|
||||||
|
path: Record<string, unknown>;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
|
||||||
|
|
||||||
|
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||||
|
let url = _url;
|
||||||
|
const matches = _url.match(PATH_PARAM_RE);
|
||||||
|
if (matches) {
|
||||||
|
for (const match of matches) {
|
||||||
|
let explode = false;
|
||||||
|
let name = match.substring(1, match.length - 1);
|
||||||
|
let style: ArraySeparatorStyle = 'simple';
|
||||||
|
|
||||||
|
if (name.endsWith('*')) {
|
||||||
|
explode = true;
|
||||||
|
name = name.substring(0, name.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.startsWith('.')) {
|
||||||
|
name = name.substring(1);
|
||||||
|
style = 'label';
|
||||||
|
} else if (name.startsWith(';')) {
|
||||||
|
name = name.substring(1);
|
||||||
|
style = 'matrix';
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = path[name];
|
||||||
|
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
url = url.replace(
|
||||||
|
match,
|
||||||
|
serializeObjectParam({
|
||||||
|
explode,
|
||||||
|
name,
|
||||||
|
style,
|
||||||
|
value: value as Record<string, unknown>,
|
||||||
|
valueOnly: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style === 'matrix') {
|
||||||
|
url = url.replace(
|
||||||
|
match,
|
||||||
|
`;${serializePrimitiveParam({
|
||||||
|
name,
|
||||||
|
value: value as string,
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replaceValue = encodeURIComponent(
|
||||||
|
style === 'label' ? `.${value as string}` : (value as string),
|
||||||
|
);
|
||||||
|
url = url.replace(match, replaceValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUrl = ({
|
||||||
|
baseUrl,
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
querySerializer,
|
||||||
|
url: _url,
|
||||||
|
}: {
|
||||||
|
baseUrl?: string;
|
||||||
|
path?: Record<string, unknown>;
|
||||||
|
query?: Record<string, unknown>;
|
||||||
|
querySerializer: QuerySerializer;
|
||||||
|
url: string;
|
||||||
|
}) => {
|
||||||
|
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
|
||||||
|
let url = (baseUrl ?? '') + pathUrl;
|
||||||
|
if (path) {
|
||||||
|
url = defaultPathSerializer({ path, url });
|
||||||
|
}
|
||||||
|
let search = query ? querySerializer(query) : '';
|
||||||
|
if (search.startsWith('?')) {
|
||||||
|
search = search.substring(1);
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
url += `?${search}`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getValidRequestBody(options: {
|
||||||
|
body?: unknown;
|
||||||
|
bodySerializer?: BodySerializer | null;
|
||||||
|
serializedBody?: unknown;
|
||||||
|
}) {
|
||||||
|
const hasBody = options.body !== undefined;
|
||||||
|
const isSerializedBody = hasBody && options.bodySerializer;
|
||||||
|
|
||||||
|
if (isSerializedBody) {
|
||||||
|
if ('serializedBody' in options) {
|
||||||
|
const hasSerializedBody =
|
||||||
|
options.serializedBody !== undefined && options.serializedBody !== '';
|
||||||
|
|
||||||
|
return hasSerializedBody ? options.serializedBody : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// not all clients implement a serializedBody property (i.e., client-axios)
|
||||||
|
return options.body !== '' ? options.body : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// plain/text body
|
||||||
|
if (hasBody) {
|
||||||
|
return options.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// no body was provided
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
export { ApiError } from './core/ApiError';
|
|
||||||
export { CancelablePromise, CancelError } from './core/CancelablePromise';
|
export { ItemsService, LoginService, type Options, PrivateService, UsersService, UtilsService } from './sdk.gen';
|
||||||
export { OpenAPI, type OpenAPIConfig } from './core/OpenAPI';
|
export type { Body_login_login_access_token, ClientOptions, HTTPValidationError, ItemCreate, ItemPublic, itemsCreateItemData, itemsCreateItemError, itemsCreateItemErrors, itemsCreateItemResponse, itemsCreateItemResponses, itemsDeleteItemData, itemsDeleteItemError, itemsDeleteItemErrors, itemsDeleteItemResponse, itemsDeleteItemResponses, ItemsPublic, itemsReadItemData, itemsReadItemError, itemsReadItemErrors, itemsReadItemResponse, itemsReadItemResponses, itemsReadItemsData, itemsReadItemsError, itemsReadItemsErrors, itemsReadItemsResponse, itemsReadItemsResponses, itemsUpdateItemData, itemsUpdateItemError, itemsUpdateItemErrors, itemsUpdateItemResponse, itemsUpdateItemResponses, ItemUpdate, loginLoginAccessTokenData, loginLoginAccessTokenError, loginLoginAccessTokenErrors, loginLoginAccessTokenResponse, loginLoginAccessTokenResponses, loginRecoverPasswordData, loginRecoverPasswordError, loginRecoverPasswordErrors, loginRecoverPasswordHtmlContentData, loginRecoverPasswordHtmlContentError, loginRecoverPasswordHtmlContentErrors, loginRecoverPasswordHtmlContentResponse, loginRecoverPasswordHtmlContentResponses, loginRecoverPasswordResponse, loginRecoverPasswordResponses, loginResetPasswordData, loginResetPasswordError, loginResetPasswordErrors, loginResetPasswordResponse, loginResetPasswordResponses, loginTestTokenData, loginTestTokenResponse, loginTestTokenResponses, Message, NewPassword, privateCreateUserData, privateCreateUserError, privateCreateUserErrors, privateCreateUserResponse, privateCreateUserResponses, PrivateUserCreate, Token, UpdatePassword, UserCreate, UserPublic, UserRegister, usersCreateUserData, usersCreateUserError, usersCreateUserErrors, usersCreateUserResponse, usersCreateUserResponses, usersDeleteUserData, usersDeleteUserError, usersDeleteUserErrors, usersDeleteUserMeData, usersDeleteUserMeResponse, usersDeleteUserMeResponses, usersDeleteUserResponse, usersDeleteUserResponses, UsersPublic, usersReadUserByIdData, usersReadUserByIdError, usersReadUserByIdErrors, usersReadUserByIdResponse, usersReadUserByIdResponses, usersReadUserMeData, usersReadUserMeResponse, usersReadUserMeResponses, usersReadUsersData, usersReadUsersError, usersReadUsersErrors, usersReadUsersResponse, usersReadUsersResponses, usersRegisterUserData, usersRegisterUserError, usersRegisterUserErrors, usersRegisterUserResponse, usersRegisterUserResponses, usersUpdatePasswordMeData, usersUpdatePasswordMeError, usersUpdatePasswordMeErrors, usersUpdatePasswordMeResponse, usersUpdatePasswordMeResponses, usersUpdateUserData, usersUpdateUserError, usersUpdateUserErrors, usersUpdateUserMeData, usersUpdateUserMeError, usersUpdateUserMeErrors, usersUpdateUserMeResponse, usersUpdateUserMeResponses, usersUpdateUserResponse, usersUpdateUserResponses, UserUpdate, UserUpdateMe, utilsHealthCheckData, utilsHealthCheckResponse, utilsHealthCheckResponses, utilsTestEmailData, utilsTestEmailError, utilsTestEmailErrors, utilsTestEmailResponse, utilsTestEmailResponses, ValidationError } from './types.gen';
|
||||||
export * from './sdk.gen';
|
|
||||||
export * from './types.gen';
|
|
||||||
|
|||||||
@@ -1,571 +0,0 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
|
||||||
|
|
||||||
export const Body_login_login_access_tokenSchema = {
|
|
||||||
properties: {
|
|
||||||
grant_type: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
pattern: '^password$'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Grant Type'
|
|
||||||
},
|
|
||||||
username: {
|
|
||||||
type: 'string',
|
|
||||||
title: 'Username'
|
|
||||||
},
|
|
||||||
password: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'password',
|
|
||||||
title: 'Password'
|
|
||||||
},
|
|
||||||
scope: {
|
|
||||||
type: 'string',
|
|
||||||
title: 'Scope',
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
client_id: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Client Id'
|
|
||||||
},
|
|
||||||
client_secret: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
format: 'password',
|
|
||||||
title: 'Client Secret'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
required: ['username', 'password'],
|
|
||||||
title: 'Body_login-login_access_token'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const HTTPValidationErrorSchema = {
|
|
||||||
properties: {
|
|
||||||
detail: {
|
|
||||||
items: {
|
|
||||||
'$ref': '#/components/schemas/ValidationError'
|
|
||||||
},
|
|
||||||
type: 'array',
|
|
||||||
title: 'Detail'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
title: 'HTTPValidationError'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const ItemCreateSchema = {
|
|
||||||
properties: {
|
|
||||||
title: {
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255,
|
|
||||||
minLength: 1,
|
|
||||||
title: 'Title'
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Description'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
required: ['title'],
|
|
||||||
title: 'ItemCreate'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const ItemPublicSchema = {
|
|
||||||
properties: {
|
|
||||||
title: {
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255,
|
|
||||||
minLength: 1,
|
|
||||||
title: 'Title'
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Description'
|
|
||||||
},
|
|
||||||
id: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'uuid',
|
|
||||||
title: 'Id'
|
|
||||||
},
|
|
||||||
owner_id: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'uuid',
|
|
||||||
title: 'Owner Id'
|
|
||||||
},
|
|
||||||
created_at: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
format: 'date-time'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Created At'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
required: ['title', 'id', 'owner_id'],
|
|
||||||
title: 'ItemPublic'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const ItemUpdateSchema = {
|
|
||||||
properties: {
|
|
||||||
title: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255,
|
|
||||||
minLength: 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Title'
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Description'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
title: 'ItemUpdate'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const ItemsPublicSchema = {
|
|
||||||
properties: {
|
|
||||||
data: {
|
|
||||||
items: {
|
|
||||||
'$ref': '#/components/schemas/ItemPublic'
|
|
||||||
},
|
|
||||||
type: 'array',
|
|
||||||
title: 'Data'
|
|
||||||
},
|
|
||||||
count: {
|
|
||||||
type: 'integer',
|
|
||||||
title: 'Count'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
required: ['data', 'count'],
|
|
||||||
title: 'ItemsPublic'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const MessageSchema = {
|
|
||||||
properties: {
|
|
||||||
message: {
|
|
||||||
type: 'string',
|
|
||||||
title: 'Message'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
required: ['message'],
|
|
||||||
title: 'Message'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const NewPasswordSchema = {
|
|
||||||
properties: {
|
|
||||||
token: {
|
|
||||||
type: 'string',
|
|
||||||
title: 'Token'
|
|
||||||
},
|
|
||||||
new_password: {
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 128,
|
|
||||||
minLength: 8,
|
|
||||||
title: 'New Password'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
required: ['token', 'new_password'],
|
|
||||||
title: 'NewPassword'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const PrivateUserCreateSchema = {
|
|
||||||
properties: {
|
|
||||||
email: {
|
|
||||||
type: 'string',
|
|
||||||
title: 'Email'
|
|
||||||
},
|
|
||||||
password: {
|
|
||||||
type: 'string',
|
|
||||||
title: 'Password'
|
|
||||||
},
|
|
||||||
full_name: {
|
|
||||||
type: 'string',
|
|
||||||
title: 'Full Name'
|
|
||||||
},
|
|
||||||
is_verified: {
|
|
||||||
type: 'boolean',
|
|
||||||
title: 'Is Verified',
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
required: ['email', 'password', 'full_name'],
|
|
||||||
title: 'PrivateUserCreate'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const TokenSchema = {
|
|
||||||
properties: {
|
|
||||||
access_token: {
|
|
||||||
type: 'string',
|
|
||||||
title: 'Access Token'
|
|
||||||
},
|
|
||||||
token_type: {
|
|
||||||
type: 'string',
|
|
||||||
title: 'Token Type',
|
|
||||||
default: 'bearer'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
required: ['access_token'],
|
|
||||||
title: 'Token'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const UpdatePasswordSchema = {
|
|
||||||
properties: {
|
|
||||||
current_password: {
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 128,
|
|
||||||
minLength: 8,
|
|
||||||
title: 'Current Password'
|
|
||||||
},
|
|
||||||
new_password: {
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 128,
|
|
||||||
minLength: 8,
|
|
||||||
title: 'New Password'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
required: ['current_password', 'new_password'],
|
|
||||||
title: 'UpdatePassword'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const UserCreateSchema = {
|
|
||||||
properties: {
|
|
||||||
email: {
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255,
|
|
||||||
format: 'email',
|
|
||||||
title: 'Email'
|
|
||||||
},
|
|
||||||
is_active: {
|
|
||||||
type: 'boolean',
|
|
||||||
title: 'Is Active',
|
|
||||||
default: true
|
|
||||||
},
|
|
||||||
is_superuser: {
|
|
||||||
type: 'boolean',
|
|
||||||
title: 'Is Superuser',
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
full_name: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Full Name'
|
|
||||||
},
|
|
||||||
password: {
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 128,
|
|
||||||
minLength: 8,
|
|
||||||
title: 'Password'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
required: ['email', 'password'],
|
|
||||||
title: 'UserCreate'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const UserPublicSchema = {
|
|
||||||
properties: {
|
|
||||||
email: {
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255,
|
|
||||||
format: 'email',
|
|
||||||
title: 'Email'
|
|
||||||
},
|
|
||||||
is_active: {
|
|
||||||
type: 'boolean',
|
|
||||||
title: 'Is Active',
|
|
||||||
default: true
|
|
||||||
},
|
|
||||||
is_superuser: {
|
|
||||||
type: 'boolean',
|
|
||||||
title: 'Is Superuser',
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
full_name: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Full Name'
|
|
||||||
},
|
|
||||||
id: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'uuid',
|
|
||||||
title: 'Id'
|
|
||||||
},
|
|
||||||
created_at: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
format: 'date-time'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Created At'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
required: ['email', 'id'],
|
|
||||||
title: 'UserPublic'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const UserRegisterSchema = {
|
|
||||||
properties: {
|
|
||||||
email: {
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255,
|
|
||||||
format: 'email',
|
|
||||||
title: 'Email'
|
|
||||||
},
|
|
||||||
password: {
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 128,
|
|
||||||
minLength: 8,
|
|
||||||
title: 'Password'
|
|
||||||
},
|
|
||||||
full_name: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Full Name'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
required: ['email', 'password'],
|
|
||||||
title: 'UserRegister'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const UserUpdateSchema = {
|
|
||||||
properties: {
|
|
||||||
email: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255,
|
|
||||||
format: 'email'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Email'
|
|
||||||
},
|
|
||||||
is_active: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'boolean'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Is Active'
|
|
||||||
},
|
|
||||||
is_superuser: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'boolean'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Is Superuser'
|
|
||||||
},
|
|
||||||
full_name: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Full Name'
|
|
||||||
},
|
|
||||||
password: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 128,
|
|
||||||
minLength: 8
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Password'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
title: 'UserUpdate'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const UserUpdateMeSchema = {
|
|
||||||
properties: {
|
|
||||||
full_name: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Full Name'
|
|
||||||
},
|
|
||||||
email: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255,
|
|
||||||
format: 'email'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Email'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
title: 'UserUpdateMe'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const UsersPublicSchema = {
|
|
||||||
properties: {
|
|
||||||
data: {
|
|
||||||
items: {
|
|
||||||
'$ref': '#/components/schemas/UserPublic'
|
|
||||||
},
|
|
||||||
type: 'array',
|
|
||||||
title: 'Data'
|
|
||||||
},
|
|
||||||
count: {
|
|
||||||
type: 'integer',
|
|
||||||
title: 'Count'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
required: ['data', 'count'],
|
|
||||||
title: 'UsersPublic'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const ValidationErrorSchema = {
|
|
||||||
properties: {
|
|
||||||
loc: {
|
|
||||||
items: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'integer'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
type: 'array',
|
|
||||||
title: 'Location'
|
|
||||||
},
|
|
||||||
msg: {
|
|
||||||
type: 'string',
|
|
||||||
title: 'Message'
|
|
||||||
},
|
|
||||||
type: {
|
|
||||||
type: 'string',
|
|
||||||
title: 'Error Type'
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
title: 'Input'
|
|
||||||
},
|
|
||||||
ctx: {
|
|
||||||
type: 'object',
|
|
||||||
title: 'Context'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
required: ['loc', 'msg', 'type'],
|
|
||||||
title: 'ValidationError'
|
|
||||||
} as const;
|
|
||||||
+256
-340
@@ -1,236 +1,97 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import type { CancelablePromise } from './core/CancelablePromise';
|
import { type Client, type Options as Options2, type TDataShape, urlSearchParamsBodySerializer } from './client';
|
||||||
import { OpenAPI } from './core/OpenAPI';
|
import { client } from './client.gen';
|
||||||
import { request as __request } from './core/request';
|
import type { itemsCreateItemData, itemsCreateItemErrors, itemsCreateItemResponses, itemsDeleteItemData, itemsDeleteItemErrors, itemsDeleteItemResponses, itemsReadItemData, itemsReadItemErrors, itemsReadItemResponses, itemsReadItemsData, itemsReadItemsErrors, itemsReadItemsResponses, itemsUpdateItemData, itemsUpdateItemErrors, itemsUpdateItemResponses, loginLoginAccessTokenData, loginLoginAccessTokenErrors, loginLoginAccessTokenResponses, loginRecoverPasswordData, loginRecoverPasswordErrors, loginRecoverPasswordHtmlContentData, loginRecoverPasswordHtmlContentErrors, loginRecoverPasswordHtmlContentResponses, loginRecoverPasswordResponses, loginResetPasswordData, loginResetPasswordErrors, loginResetPasswordResponses, loginTestTokenData, loginTestTokenResponses, privateCreateUserData, privateCreateUserErrors, privateCreateUserResponses, usersCreateUserData, usersCreateUserErrors, usersCreateUserResponses, usersDeleteUserData, usersDeleteUserErrors, usersDeleteUserMeData, usersDeleteUserMeResponses, usersDeleteUserResponses, usersReadUserByIdData, usersReadUserByIdErrors, usersReadUserByIdResponses, usersReadUserMeData, usersReadUserMeResponses, usersReadUsersData, usersReadUsersErrors, usersReadUsersResponses, usersRegisterUserData, usersRegisterUserErrors, usersRegisterUserResponses, usersUpdatePasswordMeData, usersUpdatePasswordMeErrors, usersUpdatePasswordMeResponses, usersUpdateUserData, usersUpdateUserErrors, usersUpdateUserMeData, usersUpdateUserMeErrors, usersUpdateUserMeResponses, usersUpdateUserResponses, utilsHealthCheckData, utilsHealthCheckResponses, utilsTestEmailData, utilsTestEmailErrors, utilsTestEmailResponses } from './types.gen';
|
||||||
import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
|
|
||||||
|
|
||||||
export class ItemsService {
|
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
|
||||||
/**
|
/**
|
||||||
* Read Items
|
* You can provide a client instance returned by `createClient()` instead of
|
||||||
* Retrieve items.
|
* individual options. This might be also useful if you want to implement a
|
||||||
* @param data The data for the request.
|
* custom client.
|
||||||
* @param data.skip
|
|
||||||
* @param data.limit
|
|
||||||
* @returns ItemsPublic Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
*/
|
||||||
public static readItems(data: ItemsReadItemsData = {}): CancelablePromise<ItemsReadItemsResponse> {
|
client?: Client;
|
||||||
return __request(OpenAPI, {
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/v1/items/',
|
|
||||||
query: {
|
|
||||||
skip: data.skip,
|
|
||||||
limit: data.limit
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
422: 'Validation Error'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create Item
|
* You can pass arbitrary values through the `meta` object. This can be
|
||||||
* Create new item.
|
* used to access values that aren't defined as part of the SDK function.
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.requestBody
|
|
||||||
* @returns ItemPublic Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
*/
|
||||||
public static createItem(data: ItemsCreateItemData): CancelablePromise<ItemsCreateItemResponse> {
|
meta?: Record<string, unknown>;
|
||||||
return __request(OpenAPI, {
|
};
|
||||||
method: 'POST',
|
|
||||||
url: '/api/v1/items/',
|
|
||||||
body: data.requestBody,
|
|
||||||
mediaType: 'application/json',
|
|
||||||
errors: {
|
|
||||||
422: 'Validation Error'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read Item
|
|
||||||
* Get item by ID.
|
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.id
|
|
||||||
* @returns ItemPublic Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
|
||||||
public static readItem(data: ItemsReadItemData): CancelablePromise<ItemsReadItemResponse> {
|
|
||||||
return __request(OpenAPI, {
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/v1/items/{id}',
|
|
||||||
path: {
|
|
||||||
id: data.id
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
422: 'Validation Error'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update Item
|
|
||||||
* Update an item.
|
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.id
|
|
||||||
* @param data.requestBody
|
|
||||||
* @returns ItemPublic Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
|
||||||
public static updateItem(data: ItemsUpdateItemData): CancelablePromise<ItemsUpdateItemResponse> {
|
|
||||||
return __request(OpenAPI, {
|
|
||||||
method: 'PUT',
|
|
||||||
url: '/api/v1/items/{id}',
|
|
||||||
path: {
|
|
||||||
id: data.id
|
|
||||||
},
|
|
||||||
body: data.requestBody,
|
|
||||||
mediaType: 'application/json',
|
|
||||||
errors: {
|
|
||||||
422: 'Validation Error'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete Item
|
|
||||||
* Delete an item.
|
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.id
|
|
||||||
* @returns Message Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
|
||||||
public static deleteItem(data: ItemsDeleteItemData): CancelablePromise<ItemsDeleteItemResponse> {
|
|
||||||
return __request(OpenAPI, {
|
|
||||||
method: 'DELETE',
|
|
||||||
url: '/api/v1/items/{id}',
|
|
||||||
path: {
|
|
||||||
id: data.id
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
422: 'Validation Error'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LoginService {
|
export class LoginService {
|
||||||
/**
|
/**
|
||||||
* Login Access Token
|
* Login Access Token
|
||||||
|
*
|
||||||
* OAuth2 compatible token login, get an access token for future requests
|
* OAuth2 compatible token login, get an access token for future requests
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.formData
|
|
||||||
* @returns Token Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
*/
|
||||||
public static loginAccessToken(data: LoginLoginAccessTokenData): CancelablePromise<LoginLoginAccessTokenResponse> {
|
public static loginAccessToken<ThrowOnError extends boolean = true>(options: Options<loginLoginAccessTokenData, ThrowOnError>) {
|
||||||
return __request(OpenAPI, {
|
return (options.client ?? client).post<loginLoginAccessTokenResponses, loginLoginAccessTokenErrors, ThrowOnError>({
|
||||||
method: 'POST',
|
...urlSearchParamsBodySerializer,
|
||||||
|
responseType: 'json',
|
||||||
url: '/api/v1/login/access-token',
|
url: '/api/v1/login/access-token',
|
||||||
formData: data.formData,
|
...options,
|
||||||
mediaType: 'application/x-www-form-urlencoded',
|
headers: {
|
||||||
errors: {
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
422: 'Validation Error'
|
...options.headers
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test Token
|
* Test Token
|
||||||
|
*
|
||||||
* Test access token
|
* Test access token
|
||||||
* @returns UserPublic Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
*/
|
||||||
public static testToken(): CancelablePromise<LoginTestTokenResponse> {
|
public static testToken<ThrowOnError extends boolean = true>(options?: Options<loginTestTokenData, ThrowOnError>) {
|
||||||
return __request(OpenAPI, {
|
return (options?.client ?? client).post<loginTestTokenResponses, unknown, ThrowOnError>({
|
||||||
method: 'POST',
|
responseType: 'json',
|
||||||
url: '/api/v1/login/test-token'
|
security: [{ scheme: 'bearer', type: 'http' }],
|
||||||
|
url: '/api/v1/login/test-token',
|
||||||
|
...options
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recover Password
|
* Recover Password
|
||||||
|
*
|
||||||
* Password Recovery
|
* Password Recovery
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.email
|
|
||||||
* @returns Message Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
*/
|
||||||
public static recoverPassword(data: LoginRecoverPasswordData): CancelablePromise<LoginRecoverPasswordResponse> {
|
public static recoverPassword<ThrowOnError extends boolean = true>(options: Options<loginRecoverPasswordData, ThrowOnError>) {
|
||||||
return __request(OpenAPI, {
|
return (options.client ?? client).post<loginRecoverPasswordResponses, loginRecoverPasswordErrors, ThrowOnError>({
|
||||||
method: 'POST',
|
responseType: 'json',
|
||||||
url: '/api/v1/password-recovery/{email}',
|
url: '/api/v1/password-recovery/{email}',
|
||||||
path: {
|
...options
|
||||||
email: data.email
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
422: 'Validation Error'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset Password
|
* Reset Password
|
||||||
|
*
|
||||||
* Reset password
|
* Reset password
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.requestBody
|
|
||||||
* @returns Message Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
*/
|
||||||
public static resetPassword(data: LoginResetPasswordData): CancelablePromise<LoginResetPasswordResponse> {
|
public static resetPassword<ThrowOnError extends boolean = true>(options: Options<loginResetPasswordData, ThrowOnError>) {
|
||||||
return __request(OpenAPI, {
|
return (options.client ?? client).post<loginResetPasswordResponses, loginResetPasswordErrors, ThrowOnError>({
|
||||||
method: 'POST',
|
responseType: 'json',
|
||||||
url: '/api/v1/reset-password/',
|
url: '/api/v1/reset-password/',
|
||||||
body: data.requestBody,
|
...options,
|
||||||
mediaType: 'application/json',
|
headers: {
|
||||||
errors: {
|
'Content-Type': 'application/json',
|
||||||
422: 'Validation Error'
|
...options.headers
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recover Password Html Content
|
* Recover Password Html Content
|
||||||
|
*
|
||||||
* HTML Content for Password Recovery
|
* HTML Content for Password Recovery
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.email
|
|
||||||
* @returns string Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
*/
|
||||||
public static recoverPasswordHtmlContent(data: LoginRecoverPasswordHtmlContentData): CancelablePromise<LoginRecoverPasswordHtmlContentResponse> {
|
public static recoverPasswordHtmlContent<ThrowOnError extends boolean = true>(options: Options<loginRecoverPasswordHtmlContentData, ThrowOnError>) {
|
||||||
return __request(OpenAPI, {
|
return (options.client ?? client).post<loginRecoverPasswordHtmlContentResponses, loginRecoverPasswordHtmlContentErrors, ThrowOnError>({
|
||||||
method: 'POST',
|
responseType: 'text',
|
||||||
|
security: [{ scheme: 'bearer', type: 'http' }],
|
||||||
url: '/api/v1/password-recovery-html-content/{email}',
|
url: '/api/v1/password-recovery-html-content/{email}',
|
||||||
path: {
|
...options
|
||||||
email: data.email
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
422: 'Validation Error'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PrivateService {
|
|
||||||
/**
|
|
||||||
* Create User
|
|
||||||
* Create a new user.
|
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.requestBody
|
|
||||||
* @returns UserPublic Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
|
||||||
public static createUser(data: PrivateCreateUserData): CancelablePromise<PrivateCreateUserResponse> {
|
|
||||||
return __request(OpenAPI, {
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/v1/private/users/',
|
|
||||||
body: data.requestBody,
|
|
||||||
mediaType: 'application/json',
|
|
||||||
errors: {
|
|
||||||
422: 'Validation Error'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,195 +99,159 @@ export class PrivateService {
|
|||||||
export class UsersService {
|
export class UsersService {
|
||||||
/**
|
/**
|
||||||
* Read Users
|
* Read Users
|
||||||
|
*
|
||||||
* Retrieve users.
|
* Retrieve users.
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.skip
|
|
||||||
* @param data.limit
|
|
||||||
* @returns UsersPublic Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
*/
|
||||||
public static readUsers(data: UsersReadUsersData = {}): CancelablePromise<UsersReadUsersResponse> {
|
public static readUsers<ThrowOnError extends boolean = true>(options?: Options<usersReadUsersData, ThrowOnError>) {
|
||||||
return __request(OpenAPI, {
|
return (options?.client ?? client).get<usersReadUsersResponses, usersReadUsersErrors, ThrowOnError>({
|
||||||
method: 'GET',
|
responseType: 'json',
|
||||||
|
security: [{ scheme: 'bearer', type: 'http' }],
|
||||||
url: '/api/v1/users/',
|
url: '/api/v1/users/',
|
||||||
query: {
|
...options
|
||||||
skip: data.skip,
|
|
||||||
limit: data.limit
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
422: 'Validation Error'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create User
|
* Create User
|
||||||
|
*
|
||||||
* Create new user.
|
* Create new user.
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.requestBody
|
|
||||||
* @returns UserPublic Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
*/
|
||||||
public static createUser(data: UsersCreateUserData): CancelablePromise<UsersCreateUserResponse> {
|
public static createUser<ThrowOnError extends boolean = true>(options: Options<usersCreateUserData, ThrowOnError>) {
|
||||||
return __request(OpenAPI, {
|
return (options.client ?? client).post<usersCreateUserResponses, usersCreateUserErrors, ThrowOnError>({
|
||||||
method: 'POST',
|
responseType: 'json',
|
||||||
|
security: [{ scheme: 'bearer', type: 'http' }],
|
||||||
url: '/api/v1/users/',
|
url: '/api/v1/users/',
|
||||||
body: data.requestBody,
|
...options,
|
||||||
mediaType: 'application/json',
|
headers: {
|
||||||
errors: {
|
'Content-Type': 'application/json',
|
||||||
422: 'Validation Error'
|
...options.headers
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Read User Me
|
|
||||||
* Get current user.
|
|
||||||
* @returns UserPublic Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
|
||||||
public static readUserMe(): CancelablePromise<UsersReadUserMeResponse> {
|
|
||||||
return __request(OpenAPI, {
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/v1/users/me'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete User Me
|
* Delete User Me
|
||||||
|
*
|
||||||
* Delete own user.
|
* Delete own user.
|
||||||
* @returns Message Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
*/
|
||||||
public static deleteUserMe(): CancelablePromise<UsersDeleteUserMeResponse> {
|
public static deleteUserMe<ThrowOnError extends boolean = true>(options?: Options<usersDeleteUserMeData, ThrowOnError>) {
|
||||||
return __request(OpenAPI, {
|
return (options?.client ?? client).delete<usersDeleteUserMeResponses, unknown, ThrowOnError>({
|
||||||
method: 'DELETE',
|
responseType: 'json',
|
||||||
url: '/api/v1/users/me'
|
security: [{ scheme: 'bearer', type: 'http' }],
|
||||||
|
url: '/api/v1/users/me',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read User Me
|
||||||
|
*
|
||||||
|
* Get current user.
|
||||||
|
*/
|
||||||
|
public static readUserMe<ThrowOnError extends boolean = true>(options?: Options<usersReadUserMeData, ThrowOnError>) {
|
||||||
|
return (options?.client ?? client).get<usersReadUserMeResponses, unknown, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [{ scheme: 'bearer', type: 'http' }],
|
||||||
|
url: '/api/v1/users/me',
|
||||||
|
...options
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update User Me
|
* Update User Me
|
||||||
|
*
|
||||||
* Update own user.
|
* Update own user.
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.requestBody
|
|
||||||
* @returns UserPublic Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
*/
|
||||||
public static updateUserMe(data: UsersUpdateUserMeData): CancelablePromise<UsersUpdateUserMeResponse> {
|
public static updateUserMe<ThrowOnError extends boolean = true>(options: Options<usersUpdateUserMeData, ThrowOnError>) {
|
||||||
return __request(OpenAPI, {
|
return (options.client ?? client).patch<usersUpdateUserMeResponses, usersUpdateUserMeErrors, ThrowOnError>({
|
||||||
method: 'PATCH',
|
responseType: 'json',
|
||||||
|
security: [{ scheme: 'bearer', type: 'http' }],
|
||||||
url: '/api/v1/users/me',
|
url: '/api/v1/users/me',
|
||||||
body: data.requestBody,
|
...options,
|
||||||
mediaType: 'application/json',
|
headers: {
|
||||||
errors: {
|
'Content-Type': 'application/json',
|
||||||
422: 'Validation Error'
|
...options.headers
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update Password Me
|
* Update Password Me
|
||||||
|
*
|
||||||
* Update own password.
|
* Update own password.
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.requestBody
|
|
||||||
* @returns Message Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
*/
|
||||||
public static updatePasswordMe(data: UsersUpdatePasswordMeData): CancelablePromise<UsersUpdatePasswordMeResponse> {
|
public static updatePasswordMe<ThrowOnError extends boolean = true>(options: Options<usersUpdatePasswordMeData, ThrowOnError>) {
|
||||||
return __request(OpenAPI, {
|
return (options.client ?? client).patch<usersUpdatePasswordMeResponses, usersUpdatePasswordMeErrors, ThrowOnError>({
|
||||||
method: 'PATCH',
|
responseType: 'json',
|
||||||
|
security: [{ scheme: 'bearer', type: 'http' }],
|
||||||
url: '/api/v1/users/me/password',
|
url: '/api/v1/users/me/password',
|
||||||
body: data.requestBody,
|
...options,
|
||||||
mediaType: 'application/json',
|
headers: {
|
||||||
errors: {
|
'Content-Type': 'application/json',
|
||||||
422: 'Validation Error'
|
...options.headers
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register User
|
* Register User
|
||||||
|
*
|
||||||
* Create new user without the need to be logged in.
|
* Create new user without the need to be logged in.
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.requestBody
|
|
||||||
* @returns UserPublic Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
*/
|
||||||
public static registerUser(data: UsersRegisterUserData): CancelablePromise<UsersRegisterUserResponse> {
|
public static registerUser<ThrowOnError extends boolean = true>(options: Options<usersRegisterUserData, ThrowOnError>) {
|
||||||
return __request(OpenAPI, {
|
return (options.client ?? client).post<usersRegisterUserResponses, usersRegisterUserErrors, ThrowOnError>({
|
||||||
method: 'POST',
|
responseType: 'json',
|
||||||
url: '/api/v1/users/signup',
|
url: '/api/v1/users/signup',
|
||||||
body: data.requestBody,
|
...options,
|
||||||
mediaType: 'application/json',
|
headers: {
|
||||||
errors: {
|
'Content-Type': 'application/json',
|
||||||
422: 'Validation Error'
|
...options.headers
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read User By Id
|
|
||||||
* Get a specific user by id.
|
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.userId
|
|
||||||
* @returns UserPublic Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
|
||||||
public static readUserById(data: UsersReadUserByIdData): CancelablePromise<UsersReadUserByIdResponse> {
|
|
||||||
return __request(OpenAPI, {
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/v1/users/{user_id}',
|
|
||||||
path: {
|
|
||||||
user_id: data.userId
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
422: 'Validation Error'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update User
|
|
||||||
* Update a user.
|
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.userId
|
|
||||||
* @param data.requestBody
|
|
||||||
* @returns UserPublic Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
|
||||||
public static updateUser(data: UsersUpdateUserData): CancelablePromise<UsersUpdateUserResponse> {
|
|
||||||
return __request(OpenAPI, {
|
|
||||||
method: 'PATCH',
|
|
||||||
url: '/api/v1/users/{user_id}',
|
|
||||||
path: {
|
|
||||||
user_id: data.userId
|
|
||||||
},
|
|
||||||
body: data.requestBody,
|
|
||||||
mediaType: 'application/json',
|
|
||||||
errors: {
|
|
||||||
422: 'Validation Error'
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete User
|
* Delete User
|
||||||
|
*
|
||||||
* Delete a user.
|
* Delete a user.
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.userId
|
|
||||||
* @returns Message Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
*/
|
||||||
public static deleteUser(data: UsersDeleteUserData): CancelablePromise<UsersDeleteUserResponse> {
|
public static deleteUser<ThrowOnError extends boolean = true>(options: Options<usersDeleteUserData, ThrowOnError>) {
|
||||||
return __request(OpenAPI, {
|
return (options.client ?? client).delete<usersDeleteUserResponses, usersDeleteUserErrors, ThrowOnError>({
|
||||||
method: 'DELETE',
|
responseType: 'json',
|
||||||
|
security: [{ scheme: 'bearer', type: 'http' }],
|
||||||
url: '/api/v1/users/{user_id}',
|
url: '/api/v1/users/{user_id}',
|
||||||
path: {
|
...options
|
||||||
user_id: data.userId
|
});
|
||||||
},
|
}
|
||||||
errors: {
|
|
||||||
422: 'Validation Error'
|
/**
|
||||||
|
* Read User By Id
|
||||||
|
*
|
||||||
|
* Get a specific user by id.
|
||||||
|
*/
|
||||||
|
public static readUserById<ThrowOnError extends boolean = true>(options: Options<usersReadUserByIdData, ThrowOnError>) {
|
||||||
|
return (options.client ?? client).get<usersReadUserByIdResponses, usersReadUserByIdErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [{ scheme: 'bearer', type: 'http' }],
|
||||||
|
url: '/api/v1/users/{user_id}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update User
|
||||||
|
*
|
||||||
|
* Update a user.
|
||||||
|
*/
|
||||||
|
public static updateUser<ThrowOnError extends boolean = true>(options: Options<usersUpdateUserData, ThrowOnError>) {
|
||||||
|
return (options.client ?? client).patch<usersUpdateUserResponses, usersUpdateUserErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [{ scheme: 'bearer', type: 'http' }],
|
||||||
|
url: '/api/v1/users/{user_id}',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -435,34 +260,125 @@ export class UsersService {
|
|||||||
export class UtilsService {
|
export class UtilsService {
|
||||||
/**
|
/**
|
||||||
* Test Email
|
* Test Email
|
||||||
|
*
|
||||||
* Test emails.
|
* Test emails.
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.emailTo
|
|
||||||
* @returns Message Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
*/
|
||||||
public static testEmail(data: UtilsTestEmailData): CancelablePromise<UtilsTestEmailResponse> {
|
public static testEmail<ThrowOnError extends boolean = true>(options: Options<utilsTestEmailData, ThrowOnError>) {
|
||||||
return __request(OpenAPI, {
|
return (options.client ?? client).post<utilsTestEmailResponses, utilsTestEmailErrors, ThrowOnError>({
|
||||||
method: 'POST',
|
responseType: 'json',
|
||||||
|
security: [{ scheme: 'bearer', type: 'http' }],
|
||||||
url: '/api/v1/utils/test-email/',
|
url: '/api/v1/utils/test-email/',
|
||||||
query: {
|
...options
|
||||||
email_to: data.emailTo
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
422: 'Validation Error'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Health Check
|
* Health Check
|
||||||
* @returns boolean Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
*/
|
||||||
public static healthCheck(): CancelablePromise<UtilsHealthCheckResponse> {
|
public static healthCheck<ThrowOnError extends boolean = true>(options?: Options<utilsHealthCheckData, ThrowOnError>) {
|
||||||
return __request(OpenAPI, {
|
return (options?.client ?? client).get<utilsHealthCheckResponses, unknown, ThrowOnError>({
|
||||||
method: 'GET',
|
responseType: 'json',
|
||||||
url: '/api/v1/utils/health-check/'
|
url: '/api/v1/utils/health-check/',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ItemsService {
|
||||||
|
/**
|
||||||
|
* Read Items
|
||||||
|
*
|
||||||
|
* Retrieve items.
|
||||||
|
*/
|
||||||
|
public static readItems<ThrowOnError extends boolean = true>(options?: Options<itemsReadItemsData, ThrowOnError>) {
|
||||||
|
return (options?.client ?? client).get<itemsReadItemsResponses, itemsReadItemsErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [{ scheme: 'bearer', type: 'http' }],
|
||||||
|
url: '/api/v1/items/',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Item
|
||||||
|
*
|
||||||
|
* Create new item.
|
||||||
|
*/
|
||||||
|
public static createItem<ThrowOnError extends boolean = true>(options: Options<itemsCreateItemData, ThrowOnError>) {
|
||||||
|
return (options.client ?? client).post<itemsCreateItemResponses, itemsCreateItemErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [{ scheme: 'bearer', type: 'http' }],
|
||||||
|
url: '/api/v1/items/',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Item
|
||||||
|
*
|
||||||
|
* Delete an item.
|
||||||
|
*/
|
||||||
|
public static deleteItem<ThrowOnError extends boolean = true>(options: Options<itemsDeleteItemData, ThrowOnError>) {
|
||||||
|
return (options.client ?? client).delete<itemsDeleteItemResponses, itemsDeleteItemErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [{ scheme: 'bearer', type: 'http' }],
|
||||||
|
url: '/api/v1/items/{id}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read Item
|
||||||
|
*
|
||||||
|
* Get item by ID.
|
||||||
|
*/
|
||||||
|
public static readItem<ThrowOnError extends boolean = true>(options: Options<itemsReadItemData, ThrowOnError>) {
|
||||||
|
return (options.client ?? client).get<itemsReadItemResponses, itemsReadItemErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [{ scheme: 'bearer', type: 'http' }],
|
||||||
|
url: '/api/v1/items/{id}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Item
|
||||||
|
*
|
||||||
|
* Update an item.
|
||||||
|
*/
|
||||||
|
public static updateItem<ThrowOnError extends boolean = true>(options: Options<itemsUpdateItemData, ThrowOnError>) {
|
||||||
|
return (options.client ?? client).put<itemsUpdateItemResponses, itemsUpdateItemErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
security: [{ scheme: 'bearer', type: 'http' }],
|
||||||
|
url: '/api/v1/items/{id}',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PrivateService {
|
||||||
|
/**
|
||||||
|
* Create User
|
||||||
|
*
|
||||||
|
* Create a new user.
|
||||||
|
*/
|
||||||
|
public static createUser<ThrowOnError extends boolean = true>(options: Options<privateCreateUserData, ThrowOnError>) {
|
||||||
|
return (options.client ?? client).post<privateCreateUserResponses, privateCreateUserErrors, ThrowOnError>({
|
||||||
|
responseType: 'json',
|
||||||
|
url: '/api/v1/private/users/',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+815
-108
File diff suppressed because it is too large
Load Diff
@@ -72,8 +72,7 @@ const AddUser = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (data: UserCreate) =>
|
mutationFn: (data: UserCreate) => UsersService.createUser({ body: data }),
|
||||||
UsersService.createUser({ requestBody: data }),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
showSuccessToast("User created successfully")
|
showSuccessToast("User created successfully")
|
||||||
form.reset()
|
form.reset()
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const DeleteUser = ({ id, onSuccess }: DeleteUserProps) => {
|
|||||||
const { handleSubmit } = useForm()
|
const { handleSubmit } = useForm()
|
||||||
|
|
||||||
const deleteUser = async (id: string) => {
|
const deleteUser = async (id: string) => {
|
||||||
await UsersService.deleteUser({ userId: id })
|
await UsersService.deleteUser({ path: { user_id: id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const EditUser = ({ user, onSuccess }: EditUserProps) => {
|
|||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (data: FormData) =>
|
mutationFn: (data: FormData) =>
|
||||||
UsersService.updateUser({ userId: user.id, requestBody: data }),
|
UsersService.updateUser({ path: { user_id: user.id }, body: data }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
showSuccessToast("User updated successfully")
|
showSuccessToast("User updated successfully")
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
|
|||||||
@@ -53,8 +53,7 @@ const AddItem = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (data: ItemCreate) =>
|
mutationFn: (data: ItemCreate) => ItemsService.createItem({ body: data }),
|
||||||
ItemsService.createItem({ requestBody: data }),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
showSuccessToast("Item created successfully")
|
showSuccessToast("Item created successfully")
|
||||||
form.reset()
|
form.reset()
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const DeleteItem = ({ id, onSuccess }: DeleteItemProps) => {
|
|||||||
const { handleSubmit } = useForm()
|
const { handleSubmit } = useForm()
|
||||||
|
|
||||||
const deleteItem = async (id: string) => {
|
const deleteItem = async (id: string) => {
|
||||||
await ItemsService.deleteItem({ id: id })
|
await ItemsService.deleteItem({ path: { id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const EditItem = ({ item, onSuccess }: EditItemProps) => {
|
|||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (data: FormData) =>
|
mutationFn: (data: FormData) =>
|
||||||
ItemsService.updateItem({ id: item.id, requestBody: data }),
|
ItemsService.updateItem({ path: { id: item.id }, body: data }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
showSuccessToast("Item updated successfully")
|
showSuccessToast("Item updated successfully")
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const ChangePassword = () => {
|
|||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (data: UpdatePassword) =>
|
mutationFn: (data: UpdatePassword) =>
|
||||||
UsersService.updatePasswordMe({ requestBody: data }),
|
UsersService.updatePasswordMe({ body: data }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
showSuccessToast("Password updated successfully")
|
showSuccessToast("Password updated successfully")
|
||||||
form.reset()
|
form.reset()
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const UserInformation = () => {
|
|||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (data: UserUpdateMe) =>
|
mutationFn: (data: UserUpdateMe) =>
|
||||||
UsersService.updateUserMe({ requestBody: data }),
|
UsersService.updateUserMe({ body: data }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
showSuccessToast("User updated successfully")
|
showSuccessToast("User updated successfully")
|
||||||
toggleEditMode()
|
toggleEditMode()
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ const useAuth = () => {
|
|||||||
|
|
||||||
const { data: user } = useQuery<UserPublic | null, Error>({
|
const { data: user } = useQuery<UserPublic | null, Error>({
|
||||||
queryKey: ["currentUser"],
|
queryKey: ["currentUser"],
|
||||||
queryFn: UsersService.readUserMe,
|
queryFn: async () => (await UsersService.readUserMe()).data,
|
||||||
enabled: isLoggedIn(),
|
enabled: isLoggedIn(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const signUpMutation = useMutation({
|
const signUpMutation = useMutation({
|
||||||
mutationFn: (data: UserRegister) =>
|
mutationFn: (data: UserRegister) =>
|
||||||
UsersService.registerUser({ requestBody: data }),
|
UsersService.registerUser({ body: data }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
navigate({ to: "/login" })
|
navigate({ to: "/login" })
|
||||||
},
|
},
|
||||||
@@ -40,9 +40,9 @@ const useAuth = () => {
|
|||||||
|
|
||||||
const login = async (data: AccessToken) => {
|
const login = async (data: AccessToken) => {
|
||||||
const response = await LoginService.loginAccessToken({
|
const response = await LoginService.loginAccessToken({
|
||||||
formData: data,
|
body: data,
|
||||||
})
|
})
|
||||||
localStorage.setItem("access_token", response.access_token)
|
localStorage.setItem("access_token", response.data.access_token)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginMutation = useMutation({
|
const loginMutation = useMutation({
|
||||||
|
|||||||
+10
-6
@@ -5,21 +5,25 @@ import {
|
|||||||
QueryClientProvider,
|
QueryClientProvider,
|
||||||
} from "@tanstack/react-query"
|
} from "@tanstack/react-query"
|
||||||
import { createRouter, RouterProvider } from "@tanstack/react-router"
|
import { createRouter, RouterProvider } from "@tanstack/react-router"
|
||||||
|
import { AxiosError } from "axios"
|
||||||
import { StrictMode } from "react"
|
import { StrictMode } from "react"
|
||||||
import ReactDOM from "react-dom/client"
|
import ReactDOM from "react-dom/client"
|
||||||
import { ApiError, OpenAPI } from "./client"
|
import { client } from "./client/client.gen"
|
||||||
import { ThemeProvider } from "./components/theme-provider"
|
import { ThemeProvider } from "./components/theme-provider"
|
||||||
import { Toaster } from "./components/ui/sonner"
|
import { Toaster } from "./components/ui/sonner"
|
||||||
import "./index.css"
|
import "./index.css"
|
||||||
import { routeTree } from "./routeTree.gen"
|
import { routeTree } from "./routeTree.gen"
|
||||||
|
|
||||||
OpenAPI.BASE = import.meta.env.VITE_API_URL
|
client.setConfig({
|
||||||
OpenAPI.TOKEN = async () => {
|
baseURL: import.meta.env.VITE_API_URL ?? "",
|
||||||
return localStorage.getItem("access_token") || ""
|
auth: () => localStorage.getItem("access_token") || "",
|
||||||
}
|
})
|
||||||
|
|
||||||
const handleApiError = (error: Error) => {
|
const handleApiError = (error: Error) => {
|
||||||
if (error instanceof ApiError && [401, 403].includes(error.status)) {
|
if (
|
||||||
|
error instanceof AxiosError &&
|
||||||
|
[401, 403].includes(error.response?.status ?? 0)
|
||||||
|
) {
|
||||||
localStorage.removeItem("access_token")
|
localStorage.removeItem("access_token")
|
||||||
window.location.href = "/login"
|
window.location.href = "/login"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,29 +9,18 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as SignupRouteImport } from './routes/signup'
|
|
||||||
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
|
||||||
import { Route as RecoverPasswordRouteImport } from './routes/recover-password'
|
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
|
||||||
import { Route as LayoutRouteImport } from './routes/_layout'
|
import { Route as LayoutRouteImport } from './routes/_layout'
|
||||||
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
|
import { Route as RecoverPasswordRouteImport } from './routes/recover-password'
|
||||||
|
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
||||||
|
import { Route as SignupRouteImport } from './routes/signup'
|
||||||
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
||||||
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
|
||||||
import { Route as LayoutItemsRouteImport } from './routes/_layout/items'
|
|
||||||
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
||||||
|
import { Route as LayoutItemsRouteImport } from './routes/_layout/items'
|
||||||
|
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
||||||
|
|
||||||
const SignupRoute = SignupRouteImport.update({
|
const LayoutRoute = LayoutRouteImport.update({
|
||||||
id: '/signup',
|
id: '/_layout',
|
||||||
path: '/signup',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ResetPasswordRoute = ResetPasswordRouteImport.update({
|
|
||||||
id: '/reset-password',
|
|
||||||
path: '/reset-password',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const RecoverPasswordRoute = RecoverPasswordRouteImport.update({
|
|
||||||
id: '/recover-password',
|
|
||||||
path: '/recover-password',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
@@ -39,8 +28,19 @@ const LoginRoute = LoginRouteImport.update({
|
|||||||
path: '/login',
|
path: '/login',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const LayoutRoute = LayoutRouteImport.update({
|
const RecoverPasswordRoute = RecoverPasswordRouteImport.update({
|
||||||
id: '/_layout',
|
id: '/recover-password',
|
||||||
|
path: '/recover-password',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ResetPasswordRoute = ResetPasswordRouteImport.update({
|
||||||
|
id: '/reset-password',
|
||||||
|
path: '/reset-password',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const SignupRoute = SignupRouteImport.update({
|
||||||
|
id: '/signup',
|
||||||
|
path: '/signup',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const LayoutIndexRoute = LayoutIndexRouteImport.update({
|
const LayoutIndexRoute = LayoutIndexRouteImport.update({
|
||||||
@@ -48,9 +48,9 @@ const LayoutIndexRoute = LayoutIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => LayoutRoute,
|
getParentRoute: () => LayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
|
const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
||||||
id: '/settings',
|
id: '/admin',
|
||||||
path: '/settings',
|
path: '/admin',
|
||||||
getParentRoute: () => LayoutRoute,
|
getParentRoute: () => LayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const LayoutItemsRoute = LayoutItemsRouteImport.update({
|
const LayoutItemsRoute = LayoutItemsRouteImport.update({
|
||||||
@@ -58,9 +58,9 @@ const LayoutItemsRoute = LayoutItemsRouteImport.update({
|
|||||||
path: '/items',
|
path: '/items',
|
||||||
getParentRoute: () => LayoutRoute,
|
getParentRoute: () => LayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
|
||||||
id: '/admin',
|
id: '/settings',
|
||||||
path: '/admin',
|
path: '/settings',
|
||||||
getParentRoute: () => LayoutRoute,
|
getParentRoute: () => LayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
@@ -140,25 +140,11 @@ export interface RootRouteChildren {
|
|||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
'/signup': {
|
'/_layout': {
|
||||||
id: '/signup'
|
id: '/_layout'
|
||||||
path: '/signup'
|
path: ''
|
||||||
fullPath: '/signup'
|
fullPath: '/'
|
||||||
preLoaderRoute: typeof SignupRouteImport
|
preLoaderRoute: typeof LayoutRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/reset-password': {
|
|
||||||
id: '/reset-password'
|
|
||||||
path: '/reset-password'
|
|
||||||
fullPath: '/reset-password'
|
|
||||||
preLoaderRoute: typeof ResetPasswordRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/recover-password': {
|
|
||||||
id: '/recover-password'
|
|
||||||
path: '/recover-password'
|
|
||||||
fullPath: '/recover-password'
|
|
||||||
preLoaderRoute: typeof RecoverPasswordRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/login': {
|
'/login': {
|
||||||
@@ -168,11 +154,25 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LoginRouteImport
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/_layout': {
|
'/recover-password': {
|
||||||
id: '/_layout'
|
id: '/recover-password'
|
||||||
path: ''
|
path: '/recover-password'
|
||||||
fullPath: '/'
|
fullPath: '/recover-password'
|
||||||
preLoaderRoute: typeof LayoutRouteImport
|
preLoaderRoute: typeof RecoverPasswordRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/reset-password': {
|
||||||
|
id: '/reset-password'
|
||||||
|
path: '/reset-password'
|
||||||
|
fullPath: '/reset-password'
|
||||||
|
preLoaderRoute: typeof ResetPasswordRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/signup': {
|
||||||
|
id: '/signup'
|
||||||
|
path: '/signup'
|
||||||
|
fullPath: '/signup'
|
||||||
|
preLoaderRoute: typeof SignupRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/_layout/': {
|
'/_layout/': {
|
||||||
@@ -182,11 +182,11 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LayoutIndexRouteImport
|
preLoaderRoute: typeof LayoutIndexRouteImport
|
||||||
parentRoute: typeof LayoutRoute
|
parentRoute: typeof LayoutRoute
|
||||||
}
|
}
|
||||||
'/_layout/settings': {
|
'/_layout/admin': {
|
||||||
id: '/_layout/settings'
|
id: '/_layout/admin'
|
||||||
path: '/settings'
|
path: '/admin'
|
||||||
fullPath: '/settings'
|
fullPath: '/admin'
|
||||||
preLoaderRoute: typeof LayoutSettingsRouteImport
|
preLoaderRoute: typeof LayoutAdminRouteImport
|
||||||
parentRoute: typeof LayoutRoute
|
parentRoute: typeof LayoutRoute
|
||||||
}
|
}
|
||||||
'/_layout/items': {
|
'/_layout/items': {
|
||||||
@@ -196,11 +196,11 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LayoutItemsRouteImport
|
preLoaderRoute: typeof LayoutItemsRouteImport
|
||||||
parentRoute: typeof LayoutRoute
|
parentRoute: typeof LayoutRoute
|
||||||
}
|
}
|
||||||
'/_layout/admin': {
|
'/_layout/settings': {
|
||||||
id: '/_layout/admin'
|
id: '/_layout/settings'
|
||||||
path: '/admin'
|
path: '/settings'
|
||||||
fullPath: '/admin'
|
fullPath: '/settings'
|
||||||
preLoaderRoute: typeof LayoutAdminRouteImport
|
preLoaderRoute: typeof LayoutSettingsRouteImport
|
||||||
parentRoute: typeof LayoutRoute
|
parentRoute: typeof LayoutRoute
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user