1 parent
8405c0b1f9
commit
ea531c4e1c
29 files changed
+1453
-414
No files matched your search
@@ -0,0 +1,11 @@
|
||||
.git
|
||||
.gitea
|
||||
.env
|
||||
data
|
||||
*.db
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.venv
|
||||
docker-compose.yml
|
||||
README.md
|
||||
@@ -0,0 +1,7 @@
|
||||
# Tag de l'image Harbor à déployer (latest = dernière version de main, ou 1.2.3)
|
||||
IMAGE_TAG=latest
|
||||
# URL publique utilisée pour construire les champs "url"
|
||||
API_BASE_URL=https://api-python-001.tips-of-mine.com
|
||||
# Clé exigée dans l'en-tête X-API-Key pour les DELETE (stockée dans OpenBao : secret/api-python-001)
|
||||
ADMIN_API_KEY=
|
||||
TZ=Europe/Paris
|
||||
@@ -0,0 +1,62 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: api-python-001/api-python-001
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Tests (ruff + pytest)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: test
|
||||
push: false
|
||||
provenance: false
|
||||
|
||||
- name: Login Harbor
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ vars.HARBOR_REGISTRY }}
|
||||
username: ${{ secrets.HARBOR_USERNAME }}
|
||||
password: ${{ secrets.HARBOR_PASSWORD }}
|
||||
|
||||
- name: Métadonnées de l'image
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ vars.HARBOR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Build & push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: runtime
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: |
|
||||
APP_VERSION=${{ steps.meta.outputs.version }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
+6
-414
@@ -1,416 +1,8 @@
|
||||
# ---> VisualStudio
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
||||
!Directory.Build.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.tlog
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||
*.vbp
|
||||
|
||||
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||
*.dsw
|
||||
*.dsp
|
||||
|
||||
# Visual Studio 6 technical files
|
||||
*.ncb
|
||||
*.aps
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# Visual Studio History (VSHistory) files
|
||||
.vshistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
|
||||
# VS Code files for those working on multiple tools
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Windows Installer files from build outputs
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# JetBrains Rider
|
||||
*.sln.iml
|
||||
|
||||
# ---> VisualStudioCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
.env
|
||||
data/
|
||||
*.db
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# --- Dépendances --------------------------------------------------------------
|
||||
FROM python:3.12-slim AS builder
|
||||
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
WORKDIR /build
|
||||
COPY requirements.txt .
|
||||
RUN python -m venv /opt/venv && /opt/venv/bin/pip install -r requirements.txt
|
||||
|
||||
# --- Tests (lancés par la CI : docker build --target test) --------------------
|
||||
FROM builder AS test
|
||||
COPY requirements-dev.txt .
|
||||
RUN /opt/venv/bin/pip install -r requirements-dev.txt
|
||||
COPY . .
|
||||
RUN /opt/venv/bin/ruff check . \
|
||||
&& /opt/venv/bin/ruff format --check . \
|
||||
&& /opt/venv/bin/pytest -q
|
||||
|
||||
# --- Image d'exécution ------------------------------------------------------
|
||||
FROM python:3.12-slim AS runtime
|
||||
ARG APP_VERSION=dev
|
||||
ENV PATH=/opt/venv/bin:$PATH \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
APP_VERSION=${APP_VERSION} \
|
||||
DATABASE_URL=sqlite:////data/api.db
|
||||
RUN groupadd --gid 10001 app \
|
||||
&& useradd --uid 10001 --gid app --no-create-home --shell /usr/sbin/nologin app \
|
||||
&& mkdir /data && chown app:app /data
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
WORKDIR /app
|
||||
COPY app ./app
|
||||
USER app
|
||||
EXPOSE 8000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD ["python", "-c", "import urllib.request,sys; sys.exit(urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).status != 200)"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips", "*"]
|
||||
@@ -1,2 +1,54 @@
|
||||
# api-python-001
|
||||
|
||||
Réécriture en Python (FastAPI + SQLite) de [afuh/rick-and-morty-api](https://github.com/afuh/rick-and-morty-api)
|
||||
(Node.js/Express + MongoDB) — https://api-python-001.tips-of-mine.com
|
||||
|
||||
## Correspondance avec l'original
|
||||
|
||||
| Original (Node.js) | Ici (Python) |
|
||||
|---|---|
|
||||
| `server.js` (Express) | `app/main.py` (FastAPI) |
|
||||
| `routes/` + `handlers/` | `app/routers/` (+ `common.py` : pagination, filtres, ids multiples) |
|
||||
| `models/` (Mongoose / MongoDB) | `app/models.py` (SQLAlchemy / SQLite) |
|
||||
| `utils/helpers.js` (messages) | `app/errors.py` |
|
||||
| lecture seule | + POST / PUT, DELETE protégé par `X-API-Key` |
|
||||
| `graphql/` | chantier suivant |
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Méthode | Chemin |
|
||||
|---|---|
|
||||
| GET | `/api` — index des ressources |
|
||||
| GET | `/api/{character,location,episode}` — 20 par page, `?page=N` + filtres |
|
||||
| GET | `/api/{ressource}/1`, `/1,2,3`, `/[1,2,3]` |
|
||||
| POST | `/api/{ressource}` — `id` et `created` facultatifs (conservés si fournis) |
|
||||
| PUT | `/api/{ressource}/{id}` — remplacement complet (`id`/`created` du corps ignorés) |
|
||||
| DELETE | `/api/{ressource}/{id}` — en-tête `X-API-Key` |
|
||||
| GET | `/health`, `/docs` |
|
||||
|
||||
Filtres (partiels, insensibles à la casse) : character `name status species type gender`,
|
||||
location `name type dimension`, episode `name episode`.
|
||||
|
||||
## Format et relations
|
||||
|
||||
Réponses identiques à l'original ; une ressource lue en GET peut être renvoyée telle quelle en POST.
|
||||
Les URL (`origin`, `location`, `episode`, `residents`) sont décodées sur `/api/<ressource>/<id>`, quel que soit l'hôte.
|
||||
|
||||
- `character.episode` ↔ `episode.characters` : une seule relation. Le champ `characters` d'un épisode en entrée est ignoré.
|
||||
- `location.residents` : liste saisie telle quelle (ordre conservé), indépendante de `character.location`.
|
||||
|
||||
**Ordre d'import (n8n)** : locations → episodes → characters.
|
||||
|
||||
## CI/CD
|
||||
|
||||
`.gitea/workflows/build.yml` : tests (étape `test` du Dockerfile) puis push Harbor
|
||||
`${HARBOR_REGISTRY}/api-python-001/api-python-001` — `main` → `latest` + `sha-xxx`, tag `v1.2.3` → `1.2.3`, `1.2`.
|
||||
|
||||
## Déploiement SLDOKP03
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/api-python-001/data && cd /opt/api-python-001
|
||||
# copier docker-compose.yml et .env.example -> .env
|
||||
chown 10001:10001 data
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
import os
|
||||
|
||||
__version__ = os.getenv("APP_VERSION", "dev")
|
||||
@@ -0,0 +1,20 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
api_base_url: str = "https://api-python-001.tips-of-mine.com"
|
||||
database_url: str = "sqlite:////data/api.db"
|
||||
admin_api_key: str = ""
|
||||
|
||||
@property
|
||||
def api_root(self) -> str:
|
||||
return f"{self.api_base_url.rstrip('/')}/api"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,37 @@
|
||||
from collections.abc import Iterator
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
_settings = get_settings()
|
||||
_is_sqlite = _settings.database_url.startswith("sqlite")
|
||||
|
||||
engine = create_engine(
|
||||
_settings.database_url,
|
||||
connect_args={"check_same_thread": False} if _is_sqlite else {},
|
||||
)
|
||||
|
||||
if _is_sqlite:
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _enable_sqlite_foreign_keys(dbapi_connection, _record) -> None:
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db() -> Iterator[Session]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Messages et format d'erreur de l'original : {"error": "..."}."""
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
NO_PAGE = "There is nothing here"
|
||||
BAD_PARAM = "Hey! you must provide an id"
|
||||
BAD_ARRAY = "Bad... bad array :/"
|
||||
NOT_FOUND = {
|
||||
"character": "Character not found",
|
||||
"location": "Location not found",
|
||||
"episode": "Episode not found",
|
||||
}
|
||||
|
||||
|
||||
async def _http_error(_request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
detail = NO_PAGE if exc.status_code == 404 and exc.detail == "Not Found" else exc.detail
|
||||
return JSONResponse({"error": detail}, status_code=exc.status_code, headers=exc.headers)
|
||||
|
||||
|
||||
async def _validation_error(_request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
parts = []
|
||||
for err in exc.errors():
|
||||
loc = ".".join(str(p) for p in err["loc"][1:])
|
||||
parts.append(f"{loc}: {err['msg']}" if loc else err["msg"])
|
||||
return JSONResponse({"error": "; ".join(parts)}, status_code=422)
|
||||
|
||||
|
||||
def register(app: FastAPI) -> None:
|
||||
app.add_exception_handler(StarletteHTTPException, _http_error)
|
||||
app.add_exception_handler(RequestValidationError, _validation_error)
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import __version__, errors
|
||||
from app.config import get_settings
|
||||
from app.database import Base, engine, get_db
|
||||
from app.routers import characters, episodes, locations
|
||||
from app.schemas import ApiIndex
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
# v1 : création du schéma au démarrage (Alembic à prévoir si le modèle évolue).
|
||||
Base.metadata.create_all(engine)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="api-python-001", version=__version__, lifespan=lifespan)
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
errors.register(app)
|
||||
app.include_router(characters.router)
|
||||
app.include_router(locations.router)
|
||||
app.include_router(episodes.router)
|
||||
|
||||
|
||||
@app.get("/", tags=["api"])
|
||||
def hello() -> dict:
|
||||
return {"message": "hello"}
|
||||
|
||||
|
||||
@app.get("/api", response_model=ApiIndex, tags=["api"])
|
||||
@app.get("/api/", include_in_schema=False)
|
||||
def api_index():
|
||||
root = get_settings().api_root
|
||||
return ApiIndex(
|
||||
characters=f"{root}/character",
|
||||
locations=f"{root}/location",
|
||||
episodes=f"{root}/episode",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health", tags=["api"])
|
||||
def health(db: Session = Depends(get_db)) -> dict:
|
||||
db.execute(text("SELECT 1"))
|
||||
return {"status": "ok", "version": __version__}
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Modèle relationnel.
|
||||
|
||||
- character <-> episode : une seule table d'association, lue des deux côtés
|
||||
(`character.episode` et `episode.characters` sont toujours cohérents).
|
||||
- location.residents : liste enregistrée telle quelle, indépendante de
|
||||
`character.location` (comme l'original : « ceux qui y sont passés »).
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, String, Table
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
# Stockage en UTC « naïf » : SQLite ne conserve pas le fuseau.
|
||||
return datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
character_episode = Table(
|
||||
"character_episode",
|
||||
Base.metadata,
|
||||
Column("character_id", ForeignKey("characters.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("episode_id", ForeignKey("episodes.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class Location(Base):
|
||||
__tablename__ = "locations"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
type: Mapped[str] = mapped_column(String(100), default="")
|
||||
dimension: Mapped[str] = mapped_column(String(255), default="")
|
||||
created: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
edited: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
residents: Mapped[list["LocationResident"]] = relationship(
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
order_by="LocationResident.position",
|
||||
)
|
||||
|
||||
|
||||
class Character(Base):
|
||||
__tablename__ = "characters"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
status: Mapped[str] = mapped_column(String(20))
|
||||
species: Mapped[str] = mapped_column(String(100), default="")
|
||||
type: Mapped[str] = mapped_column(String(255), default="")
|
||||
gender: Mapped[str] = mapped_column(String(20))
|
||||
origin_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
location_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
image: Mapped[str] = mapped_column(String(2048), default="")
|
||||
created: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
edited: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
origin: Mapped[Location | None] = relationship(foreign_keys=[origin_id])
|
||||
location: Mapped[Location | None] = relationship(foreign_keys=[location_id])
|
||||
episodes: Mapped[list["Episode"]] = relationship(
|
||||
secondary=character_episode, back_populates="characters", order_by="Episode.id"
|
||||
)
|
||||
|
||||
|
||||
class Episode(Base):
|
||||
__tablename__ = "episodes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
air_date: Mapped[str] = mapped_column(String(50), default="")
|
||||
episode: Mapped[str] = mapped_column(String(6), unique=True)
|
||||
created: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
edited: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
characters: Mapped[list[Character]] = relationship(
|
||||
secondary=character_episode, back_populates="episodes", order_by="Character.id"
|
||||
)
|
||||
|
||||
|
||||
class LocationResident(Base):
|
||||
"""Resident d'un lieu. Pas de clé étrangère vers characters : l'import de
|
||||
l'original crée les lieux avant les personnages qu'ils référencent."""
|
||||
|
||||
__tablename__ = "location_residents"
|
||||
|
||||
location_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("locations.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
character_id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
position: Mapped[int] = mapped_column(default=0) # ordre de saisie conservé
|
||||
Whitespace-only changes.
@@ -0,0 +1,126 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Character, Episode, Location, LocationResident, utcnow
|
||||
from app.routers.common import (
|
||||
contains,
|
||||
ensure_free_id,
|
||||
fetch_many,
|
||||
get_by_ids,
|
||||
get_or_404,
|
||||
paginate,
|
||||
to_utc_naive,
|
||||
)
|
||||
from app.schemas import CharacterIn, CharacterOut, LinkIn, Page
|
||||
from app.security import require_admin
|
||||
from app.serializers import character_out
|
||||
from app.urls import id_from_url
|
||||
|
||||
RESOURCE = "character"
|
||||
LOAD = (
|
||||
selectinload(Character.origin),
|
||||
selectinload(Character.location),
|
||||
selectinload(Character.episodes),
|
||||
)
|
||||
|
||||
router = APIRouter(prefix=f"/api/{RESOURCE}", tags=[RESOURCE])
|
||||
|
||||
|
||||
def _location(db: Session, link: LinkIn | None) -> Location | None:
|
||||
loc_id = id_from_url(link.url, "location") if link else None
|
||||
if loc_id is None:
|
||||
return None
|
||||
location = db.get(Location, loc_id)
|
||||
if location is None:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, f"location {loc_id} inexistante")
|
||||
return location
|
||||
|
||||
|
||||
def _apply(db: Session, character: Character, payload: CharacterIn) -> None:
|
||||
character.name = payload.name
|
||||
character.status = payload.status.value
|
||||
character.species = payload.species
|
||||
character.type = payload.type
|
||||
character.gender = payload.gender.value
|
||||
character.origin = _location(db, payload.origin)
|
||||
character.location = _location(db, payload.location)
|
||||
character.image = payload.image
|
||||
episode_ids = [id_from_url(url, "episode") for url in payload.episode]
|
||||
character.episodes = fetch_many(db, Episode, [i for i in episode_ids if i], "episode")
|
||||
|
||||
|
||||
@router.get("", response_model=Page[CharacterOut])
|
||||
@router.get("/", include_in_schema=False)
|
||||
def list_characters(
|
||||
page: str | None = None,
|
||||
name: str | None = None,
|
||||
status_: str | None = Query(None, alias="status"),
|
||||
species: str | None = None,
|
||||
type_: str | None = Query(None, alias="type"),
|
||||
gender: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
stmt = select(Character)
|
||||
for column, value in (
|
||||
(Character.name, name),
|
||||
(Character.status, status_),
|
||||
(Character.species, species),
|
||||
(Character.type, type_),
|
||||
):
|
||||
if value:
|
||||
stmt = stmt.where(contains(column, value))
|
||||
if gender:
|
||||
# « male » ne doit pas remonter « Female » (même règle que l'original)
|
||||
value = gender.strip().lower()
|
||||
if value.startswith("male"):
|
||||
stmt = stmt.where(func.lower(Character.gender).startswith(value, autoescape=True))
|
||||
else:
|
||||
stmt = stmt.where(contains(Character.gender, value))
|
||||
filters = {
|
||||
"name": name,
|
||||
"status": status_,
|
||||
"species": species,
|
||||
"type": type_,
|
||||
"gender": gender,
|
||||
}
|
||||
return paginate(db, stmt, Character, page, RESOURCE, filters, character_out, LOAD)
|
||||
|
||||
|
||||
@router.get("/{ids}", response_model=CharacterOut | list[CharacterOut])
|
||||
def get_characters(ids: str, db: Session = Depends(get_db)):
|
||||
return get_by_ids(db, Character, ids, RESOURCE, character_out, LOAD)
|
||||
|
||||
|
||||
@router.post("", response_model=CharacterOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_character(payload: CharacterIn, db: Session = Depends(get_db)):
|
||||
ensure_free_id(db, Character, payload.id, RESOURCE)
|
||||
character = Character(id=payload.id)
|
||||
_apply(db, character, payload)
|
||||
if payload.created:
|
||||
character.created = to_utc_naive(payload.created)
|
||||
db.add(character)
|
||||
db.commit()
|
||||
return character_out(character)
|
||||
|
||||
|
||||
@router.put("/{character_id}", response_model=CharacterOut)
|
||||
def update_character(character_id: int, payload: CharacterIn, db: Session = Depends(get_db)):
|
||||
character = get_or_404(db, Character, character_id, RESOURCE)
|
||||
_apply(db, character, payload)
|
||||
character.edited = utcnow()
|
||||
db.commit()
|
||||
return character_out(character)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{character_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def delete_character(character_id: int, db: Session = Depends(get_db)):
|
||||
db.delete(get_or_404(db, Character, character_id, RESOURCE))
|
||||
db.execute(delete(LocationResident).where(LocationResident.character_id == character_id))
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Logique partagée : pagination, filtres, lecture par id(s), écriture."""
|
||||
|
||||
import json
|
||||
import math
|
||||
from collections.abc import Callable, Sequence
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import ColumnElement, Select, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.interfaces import LoaderOption
|
||||
|
||||
from app.config import get_settings
|
||||
from app.errors import BAD_ARRAY, BAD_PARAM, NO_PAGE, NOT_FOUND
|
||||
from app.schemas import Info, Page
|
||||
|
||||
PAGE_SIZE = 20
|
||||
|
||||
|
||||
def contains(column, value: str) -> ColumnElement[bool]:
|
||||
"""Recherche partielle insensible à la casse (comme la RegExp /i de l'original)."""
|
||||
return func.lower(column).contains(value.strip().lower(), autoescape=True)
|
||||
|
||||
|
||||
def parse_page(raw: str | None) -> int:
|
||||
try:
|
||||
page = int(raw) if raw is not None else 1
|
||||
except ValueError:
|
||||
return 1
|
||||
return page if page > 0 else 1
|
||||
|
||||
|
||||
def paginate(
|
||||
db: Session,
|
||||
stmt: Select,
|
||||
model: type,
|
||||
page_raw: str | None,
|
||||
resource: str,
|
||||
filters: dict[str, str | None],
|
||||
serialize: Callable[[Any], Any],
|
||||
options: Sequence[LoaderOption],
|
||||
) -> Page:
|
||||
page = parse_page(page_raw)
|
||||
count = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||
pages = math.ceil(count / PAGE_SIZE)
|
||||
if page > pages:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, NO_PAGE)
|
||||
rows = db.scalars(
|
||||
stmt.options(*options).order_by(model.id).offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE)
|
||||
)
|
||||
query = "".join(f"&{k}={quote(v.strip())}" for k, v in filters.items() if v)
|
||||
base = f"{get_settings().api_root}/{resource}"
|
||||
return Page(
|
||||
info=Info(
|
||||
count=count,
|
||||
pages=pages,
|
||||
next=f"{base}?page={page + 1}{query}" if page < pages else None,
|
||||
prev=f"{base}?page={page - 1}{query}" if page >= 2 else None,
|
||||
),
|
||||
results=[serialize(r) for r in rows],
|
||||
)
|
||||
|
||||
|
||||
def parse_ids(raw: str) -> int | list[int]:
|
||||
"""Accepte `1`, `1,2,3` ou `[1,2,3]` (comme l'original)."""
|
||||
raw = raw.strip()
|
||||
if raw.startswith("[") or raw.endswith("]"):
|
||||
try:
|
||||
ids = json.loads(raw)
|
||||
if not isinstance(ids, list) or not all(isinstance(i, int) for i in ids):
|
||||
raise ValueError
|
||||
return ids
|
||||
except ValueError:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, BAD_ARRAY) from None
|
||||
try:
|
||||
if "," in raw:
|
||||
return [int(part) for part in raw.split(",") if part.strip()]
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, BAD_PARAM) from None
|
||||
|
||||
|
||||
def get_by_ids(
|
||||
db: Session,
|
||||
model: type,
|
||||
raw: str,
|
||||
resource: str,
|
||||
serialize: Callable[[Any], Any],
|
||||
options: Sequence[LoaderOption],
|
||||
):
|
||||
ids = parse_ids(raw)
|
||||
if isinstance(ids, list):
|
||||
rows = db.scalars(
|
||||
select(model).options(*options).where(model.id.in_(ids)).order_by(model.id)
|
||||
)
|
||||
return [serialize(r) for r in rows]
|
||||
obj = db.get(model, ids, options=options)
|
||||
if obj is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, NOT_FOUND[resource])
|
||||
return serialize(obj)
|
||||
|
||||
|
||||
def get_or_404(db: Session, model: type, obj_id: int, resource: str):
|
||||
obj = db.get(model, obj_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, NOT_FOUND[resource])
|
||||
return obj
|
||||
|
||||
|
||||
def ensure_free_id(db: Session, model: type, obj_id: int | None, resource: str) -> None:
|
||||
if obj_id is not None and db.get(model, obj_id) is not None:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, f"{resource} {obj_id} existe déjà")
|
||||
|
||||
|
||||
def to_utc_naive(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.astimezone(UTC).replace(tzinfo=None) if value.tzinfo else value
|
||||
|
||||
|
||||
def fetch_many(db: Session, model: type, ids: list[int], resource: str) -> list:
|
||||
wanted = list(dict.fromkeys(ids))
|
||||
if not wanted:
|
||||
return []
|
||||
found = {o.id: o for o in db.scalars(select(model).where(model.id.in_(wanted)))}
|
||||
missing = [i for i in wanted if i not in found]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_CONTENT, f"{resource} inexistant(s) : {missing}"
|
||||
)
|
||||
return [found[i] for i in wanted]
|
||||
@@ -0,0 +1,88 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Episode, utcnow
|
||||
from app.routers.common import (
|
||||
contains,
|
||||
ensure_free_id,
|
||||
get_by_ids,
|
||||
get_or_404,
|
||||
paginate,
|
||||
to_utc_naive,
|
||||
)
|
||||
from app.schemas import EpisodeIn, EpisodeOut, Page
|
||||
from app.security import require_admin
|
||||
from app.serializers import episode_out
|
||||
|
||||
RESOURCE = "episode"
|
||||
LOAD = (selectinload(Episode.characters),)
|
||||
|
||||
router = APIRouter(prefix=f"/api/{RESOURCE}", tags=[RESOURCE])
|
||||
|
||||
|
||||
def _apply(db: Session, episode: Episode, payload: EpisodeIn) -> None:
|
||||
duplicate = db.scalar(
|
||||
select(Episode.id).where(Episode.episode == payload.episode, Episode.id != episode.id)
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, f"l'épisode {payload.episode} existe déjà (id {duplicate})"
|
||||
)
|
||||
episode.name = payload.name
|
||||
episode.air_date = payload.air_date
|
||||
episode.episode = payload.episode
|
||||
|
||||
|
||||
@router.get("", response_model=Page[EpisodeOut])
|
||||
@router.get("/", include_in_schema=False)
|
||||
def list_episodes(
|
||||
page: str | None = None,
|
||||
name: str | None = None,
|
||||
episode: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
stmt = select(Episode)
|
||||
for column, value in ((Episode.name, name), (Episode.episode, episode)):
|
||||
if value:
|
||||
stmt = stmt.where(contains(column, value))
|
||||
filters = {"name": name, "episode": episode}
|
||||
return paginate(db, stmt, Episode, page, RESOURCE, filters, episode_out, LOAD)
|
||||
|
||||
|
||||
@router.get("/{ids}", response_model=EpisodeOut | list[EpisodeOut])
|
||||
def get_episodes(ids: str, db: Session = Depends(get_db)):
|
||||
return get_by_ids(db, Episode, ids, RESOURCE, episode_out, LOAD)
|
||||
|
||||
|
||||
@router.post("", response_model=EpisodeOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_episode(payload: EpisodeIn, db: Session = Depends(get_db)):
|
||||
ensure_free_id(db, Episode, payload.id, RESOURCE)
|
||||
episode = Episode(id=payload.id)
|
||||
_apply(db, episode, payload)
|
||||
if payload.created:
|
||||
episode.created = to_utc_naive(payload.created)
|
||||
db.add(episode)
|
||||
db.commit()
|
||||
return episode_out(db.get(Episode, episode.id, options=LOAD))
|
||||
|
||||
|
||||
@router.put("/{episode_id}", response_model=EpisodeOut)
|
||||
def update_episode(episode_id: int, payload: EpisodeIn, db: Session = Depends(get_db)):
|
||||
episode = get_or_404(db, Episode, episode_id, RESOURCE)
|
||||
_apply(db, episode, payload)
|
||||
episode.edited = utcnow()
|
||||
db.commit()
|
||||
return episode_out(db.get(Episode, episode.id, options=LOAD))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{episode_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def delete_episode(episode_id: int, db: Session = Depends(get_db)):
|
||||
db.delete(get_or_404(db, Episode, episode_id, RESOURCE))
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,95 @@
|
||||
from fastapi import APIRouter, Depends, Query, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Location, LocationResident, utcnow
|
||||
from app.routers.common import (
|
||||
contains,
|
||||
ensure_free_id,
|
||||
get_by_ids,
|
||||
get_or_404,
|
||||
paginate,
|
||||
to_utc_naive,
|
||||
)
|
||||
from app.schemas import LocationIn, LocationOut, Page
|
||||
from app.security import require_admin
|
||||
from app.serializers import location_out
|
||||
from app.urls import id_from_url
|
||||
|
||||
RESOURCE = "location"
|
||||
LOAD = (selectinload(Location.residents),)
|
||||
|
||||
router = APIRouter(prefix=f"/api/{RESOURCE}", tags=[RESOURCE])
|
||||
|
||||
|
||||
def _apply(location: Location, payload: LocationIn) -> None:
|
||||
location.name = payload.name
|
||||
location.type = payload.type
|
||||
location.dimension = payload.dimension
|
||||
ids = [i for i in dict.fromkeys(id_from_url(u, "character") for u in payload.residents) if i]
|
||||
existing = {r.character_id: r for r in location.residents}
|
||||
residents = []
|
||||
for position, character_id in enumerate(ids):
|
||||
resident = existing.get(character_id) or LocationResident(character_id=character_id)
|
||||
resident.position = position
|
||||
residents.append(resident)
|
||||
location.residents = residents
|
||||
|
||||
|
||||
@router.get("", response_model=Page[LocationOut])
|
||||
@router.get("/", include_in_schema=False)
|
||||
def list_locations(
|
||||
page: str | None = None,
|
||||
name: str | None = None,
|
||||
type_: str | None = Query(None, alias="type"),
|
||||
dimension: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
stmt = select(Location)
|
||||
for column, value in (
|
||||
(Location.name, name),
|
||||
(Location.type, type_),
|
||||
(Location.dimension, dimension),
|
||||
):
|
||||
if value:
|
||||
stmt = stmt.where(contains(column, value))
|
||||
filters = {"name": name, "type": type_, "dimension": dimension}
|
||||
return paginate(db, stmt, Location, page, RESOURCE, filters, location_out, LOAD)
|
||||
|
||||
|
||||
@router.get("/{ids}", response_model=LocationOut | list[LocationOut])
|
||||
def get_locations(ids: str, db: Session = Depends(get_db)):
|
||||
return get_by_ids(db, Location, ids, RESOURCE, location_out, LOAD)
|
||||
|
||||
|
||||
@router.post("", response_model=LocationOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_location(payload: LocationIn, db: Session = Depends(get_db)):
|
||||
ensure_free_id(db, Location, payload.id, RESOURCE)
|
||||
location = Location(id=payload.id)
|
||||
_apply(location, payload)
|
||||
if payload.created:
|
||||
location.created = to_utc_naive(payload.created)
|
||||
db.add(location)
|
||||
db.commit()
|
||||
return location_out(db.get(Location, location.id, options=LOAD))
|
||||
|
||||
|
||||
@router.put("/{location_id}", response_model=LocationOut)
|
||||
def update_location(location_id: int, payload: LocationIn, db: Session = Depends(get_db)):
|
||||
location = get_or_404(db, Location, location_id, RESOURCE)
|
||||
_apply(location, payload)
|
||||
location.edited = utcnow()
|
||||
db.commit()
|
||||
return location_out(db.get(Location, location.id, options=LOAD))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{location_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def delete_location(location_id: int, db: Session = Depends(get_db)):
|
||||
db.delete(get_or_404(db, Location, location_id, RESOURCE))
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
"""Schémas d'entrée/sortie — format identique à rickandmortyapi.com.
|
||||
|
||||
Une ressource lue en GET peut être renvoyée telle quelle en POST : les URL de
|
||||
`origin`, `location` et `episode` sont décodées pour retrouver les identifiants.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel, Field, PositiveInt
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class Status(StrEnum):
|
||||
alive = "Alive"
|
||||
dead = "Dead"
|
||||
unknown = "unknown"
|
||||
|
||||
|
||||
class Gender(StrEnum):
|
||||
female = "Female"
|
||||
male = "Male"
|
||||
genderless = "Genderless"
|
||||
unknown = "unknown"
|
||||
|
||||
|
||||
class Link(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
|
||||
|
||||
class Info(BaseModel):
|
||||
count: int
|
||||
pages: int
|
||||
next: str | None
|
||||
prev: str | None
|
||||
|
||||
|
||||
class Page(BaseModel, Generic[T]):
|
||||
info: Info
|
||||
results: list[T]
|
||||
|
||||
|
||||
class ApiIndex(BaseModel):
|
||||
characters: str
|
||||
locations: str
|
||||
episodes: str
|
||||
|
||||
|
||||
# --- Entrées (POST / PUT) ----------------------------------------------------
|
||||
|
||||
|
||||
class LinkIn(BaseModel):
|
||||
name: str = ""
|
||||
url: str = ""
|
||||
|
||||
|
||||
class CharacterIn(BaseModel):
|
||||
id: PositiveInt | None = Field(default=None, description="Ignoré en PUT")
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
status: Status = Status.unknown
|
||||
species: str = Field(default="", max_length=100)
|
||||
type: str = Field(default="", max_length=255)
|
||||
gender: Gender = Gender.unknown
|
||||
origin: LinkIn | None = None
|
||||
location: LinkIn | None = None
|
||||
image: str = Field(default="", max_length=2048)
|
||||
episode: list[str] = Field(default_factory=list, description="URL des épisodes")
|
||||
created: datetime | None = Field(default=None, description="Ignoré en PUT")
|
||||
|
||||
|
||||
class LocationIn(BaseModel):
|
||||
id: PositiveInt | None = Field(default=None, description="Ignoré en PUT")
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
type: str = Field(default="", max_length=100)
|
||||
dimension: str = Field(default="", max_length=255)
|
||||
residents: list[str] = Field(default_factory=list, description="URL des characters")
|
||||
created: datetime | None = Field(default=None, description="Ignoré en PUT")
|
||||
|
||||
|
||||
class EpisodeIn(BaseModel):
|
||||
id: PositiveInt | None = Field(default=None, description="Ignoré en PUT")
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
air_date: str = Field(default="", max_length=50, examples=["December 2, 2013"])
|
||||
episode: str = Field(pattern=r"^S\d{2}E\d{2}$", examples=["S01E01"])
|
||||
characters: list[str] = Field(
|
||||
default_factory=list, description="Ignoré : déduit de character.episode"
|
||||
)
|
||||
created: datetime | None = Field(default=None, description="Ignoré en PUT")
|
||||
|
||||
|
||||
# --- Sorties (ordre des champs identique à l'original) -----------------------
|
||||
|
||||
|
||||
class CharacterOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
status: str
|
||||
species: str
|
||||
type: str
|
||||
gender: str
|
||||
origin: Link
|
||||
location: Link
|
||||
image: str
|
||||
episode: list[str]
|
||||
url: str
|
||||
created: str
|
||||
|
||||
|
||||
class LocationOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
type: str
|
||||
dimension: str
|
||||
residents: list[str]
|
||||
url: str
|
||||
created: str
|
||||
|
||||
|
||||
class EpisodeOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
air_date: str
|
||||
episode: str
|
||||
characters: list[str]
|
||||
url: str
|
||||
created: str
|
||||
@@ -0,0 +1,24 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import HTTPException, Security, status
|
||||
from fastapi.security import APIKeyHeader
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
|
||||
def require_admin(api_key: str | None = Security(api_key_header)) -> None:
|
||||
"""Protège les DELETE par une clé statique (futur point d'accroche des tokens)."""
|
||||
expected = get_settings().admin_api_key
|
||||
if not expected:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
"Suppression désactivée : ADMIN_API_KEY non configurée",
|
||||
)
|
||||
if not api_key or not secrets.compare_digest(api_key.encode(), expected.encode()):
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
"Clé API absente ou invalide",
|
||||
headers={"WWW-Authenticate": "ApiKey"},
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.models import Character, Episode, Location
|
||||
from app.schemas import CharacterOut, EpisodeOut, Link, LocationOut
|
||||
from app.urls import resource_url
|
||||
|
||||
UNKNOWN = Link(name="unknown", url="")
|
||||
|
||||
|
||||
def iso(value: datetime) -> str:
|
||||
"""Format de l'original : 2017-11-04T18:48:46.250Z"""
|
||||
aware = value if value.tzinfo else value.replace(tzinfo=UTC)
|
||||
return aware.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _link(location: Location | None) -> Link:
|
||||
if location is None:
|
||||
return UNKNOWN
|
||||
return Link(name=location.name, url=resource_url("location", location.id))
|
||||
|
||||
|
||||
def character_out(c: Character) -> CharacterOut:
|
||||
return CharacterOut(
|
||||
id=c.id,
|
||||
name=c.name,
|
||||
status=c.status,
|
||||
species=c.species,
|
||||
type=c.type,
|
||||
gender=c.gender,
|
||||
origin=_link(c.origin),
|
||||
location=_link(c.location),
|
||||
image=c.image,
|
||||
episode=[resource_url("episode", i) for i in sorted(e.id for e in c.episodes)],
|
||||
url=resource_url("character", c.id),
|
||||
created=iso(c.created),
|
||||
)
|
||||
|
||||
|
||||
def location_out(loc: Location) -> LocationOut:
|
||||
return LocationOut(
|
||||
id=loc.id,
|
||||
name=loc.name,
|
||||
type=loc.type,
|
||||
dimension=loc.dimension,
|
||||
residents=[
|
||||
resource_url("character", r.character_id)
|
||||
for r in sorted(loc.residents, key=lambda r: r.position)
|
||||
],
|
||||
url=resource_url("location", loc.id),
|
||||
created=iso(loc.created),
|
||||
)
|
||||
|
||||
|
||||
def episode_out(e: Episode) -> EpisodeOut:
|
||||
return EpisodeOut(
|
||||
id=e.id,
|
||||
name=e.name,
|
||||
air_date=e.air_date,
|
||||
episode=e.episode,
|
||||
characters=[resource_url("character", i) for i in sorted(c.id for c in e.characters)],
|
||||
url=resource_url("episode", e.id),
|
||||
created=iso(e.created),
|
||||
)
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
"""Construction et décodage des URL de ressources."""
|
||||
|
||||
import re
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
_RESOURCE_URL = re.compile(r"/api/(character|location|episode)/(\d+)/?$")
|
||||
|
||||
|
||||
def resource_url(resource: str, obj_id: int) -> str:
|
||||
return f"{get_settings().api_root}/{resource}/{obj_id}"
|
||||
|
||||
|
||||
def id_from_url(url: str, resource: str) -> int | None:
|
||||
"""Extrait l'id d'une URL `.../api/<resource>/<id>` (hôte indifférent).
|
||||
|
||||
Une URL vide (origine « unknown ») renvoie None.
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
match = _RESOURCE_URL.search(url)
|
||||
if match is None or match.group(1) != resource:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_CONTENT, f"URL de {resource} invalide : {url}"
|
||||
)
|
||||
return int(match.group(2))
|
||||
@@ -0,0 +1,31 @@
|
||||
# Déploiement de test sur SLDOKP03 : /opt/api-python-001 (docker-compose.yml + .env)
|
||||
services:
|
||||
api-python-001:
|
||||
image: registry.tips-of-mine.com/api-python-001/api-python-001:${IMAGE_TAG:-latest}
|
||||
container_name: api-python-001-app
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./data:/data
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
networks:
|
||||
- traefik_front_network
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=traefik_front_network
|
||||
- traefik.http.routers.api-python-001.rule=Host(`api-python-001.tips-of-mine.com`)
|
||||
- traefik.http.routers.api-python-001.entrypoints=https
|
||||
- traefik.http.routers.api-python-001.tls=true
|
||||
- traefik.http.routers.api-python-001.service=api-python-001
|
||||
- traefik.http.services.api-python-001.loadbalancer.server.port=8000
|
||||
- com.centurylinklabs.watchtower.enable=true
|
||||
|
||||
networks:
|
||||
traefik_front_network:
|
||||
external: true
|
||||
@@ -0,0 +1,17 @@
|
||||
[project]
|
||||
name = "api-python-001"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "B", "UP", "S", "N"]
|
||||
ignore = ["B008"] # Depends()/Query() dans les signatures : idiome FastAPI
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["S101", "S105", "S106"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,4 @@
|
||||
-r requirements.txt
|
||||
pytest==9.1.1
|
||||
httpx==0.28.1
|
||||
ruff==0.16.8
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi==0.141.1
|
||||
uvicorn==0.46.0
|
||||
SQLAlchemy==2.0.54
|
||||
pydantic==2.13.3
|
||||
pydantic-settings==2.14.0
|
||||
Whitespace-only changes.
@@ -0,0 +1,124 @@
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
# Variables positionnées AVANT l'import de l'application (settings mis en cache).
|
||||
_TMP = tempfile.mkdtemp(prefix="api-python-001-")
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{_TMP}/test.db"
|
||||
os.environ["ADMIN_API_KEY"] = "test-admin-key"
|
||||
os.environ["API_BASE_URL"] = "https://api.test"
|
||||
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from app.database import Base, engine # noqa: E402
|
||||
from app.main import app # noqa: E402
|
||||
|
||||
ADMIN = {"X-API-Key": "test-admin-key"}
|
||||
SRC = "https://rickandmortyapi.com/api"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
Base.metadata.drop_all(engine)
|
||||
Base.metadata.create_all(engine)
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seeded(client):
|
||||
"""Jeu minimal au format exact de rickandmortyapi.com (import n8n simulé)."""
|
||||
locations = [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Earth (C-137)",
|
||||
"type": "Planet",
|
||||
"dimension": "Dimension C-137",
|
||||
"residents": [f"{SRC}/character/38"],
|
||||
"url": f"{SRC}/location/1",
|
||||
"created": "2017-11-10T12:42:04.162Z",
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Citadel of Ricks",
|
||||
"type": "Space station",
|
||||
"dimension": "unknown",
|
||||
"residents": [f"{SRC}/character/2", f"{SRC}/character/8"],
|
||||
"url": f"{SRC}/location/3",
|
||||
"created": "2017-11-10T13:08:13.191Z",
|
||||
},
|
||||
]
|
||||
episodes = [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Pilot",
|
||||
"air_date": "December 2, 2013",
|
||||
"episode": "S01E01",
|
||||
"characters": [f"{SRC}/character/1"],
|
||||
"url": f"{SRC}/episode/1",
|
||||
"created": "2017-11-10T12:56:33.798Z",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Lawnmower Dog",
|
||||
"air_date": "December 9, 2013",
|
||||
"episode": "S01E02",
|
||||
"characters": [],
|
||||
"url": f"{SRC}/episode/2",
|
||||
"created": "2017-11-10T12:56:33.916Z",
|
||||
},
|
||||
]
|
||||
characters = [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Rick Sanchez",
|
||||
"status": "Alive",
|
||||
"species": "Human",
|
||||
"type": "",
|
||||
"gender": "Male",
|
||||
"origin": {"name": "Earth (C-137)", "url": f"{SRC}/location/1"},
|
||||
"location": {"name": "Citadel of Ricks", "url": f"{SRC}/location/3"},
|
||||
"image": f"{SRC}/character/avatar/1.jpeg",
|
||||
"episode": [f"{SRC}/episode/1", f"{SRC}/episode/2"],
|
||||
"url": f"{SRC}/character/1",
|
||||
"created": "2017-11-04T18:48:46.250Z",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Morty Smith",
|
||||
"status": "Alive",
|
||||
"species": "Human",
|
||||
"type": "",
|
||||
"gender": "Male",
|
||||
"origin": {"name": "unknown", "url": ""},
|
||||
"location": {"name": "Citadel of Ricks", "url": f"{SRC}/location/3"},
|
||||
"image": f"{SRC}/character/avatar/2.jpeg",
|
||||
"episode": [f"{SRC}/episode/1"],
|
||||
"url": f"{SRC}/character/2",
|
||||
"created": "2017-11-04T18:50:21.651Z",
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Summer Smith",
|
||||
"status": "Alive",
|
||||
"species": "Human",
|
||||
"type": "",
|
||||
"gender": "Female",
|
||||
"origin": {"name": "unknown", "url": ""},
|
||||
"location": {"name": "Earth (C-137)", "url": f"{SRC}/location/1"},
|
||||
"image": "",
|
||||
"episode": [],
|
||||
"url": f"{SRC}/character/3",
|
||||
"created": "2017-11-04T19:09:56.428Z",
|
||||
},
|
||||
]
|
||||
for resource, items in (
|
||||
("location", locations),
|
||||
("episode", episodes),
|
||||
("character", characters),
|
||||
):
|
||||
for item in items:
|
||||
r = client.post(f"/api/{resource}", json=item)
|
||||
assert r.status_code == 201, r.text
|
||||
return client
|
||||
@@ -0,0 +1,101 @@
|
||||
API = "https://api.test/api"
|
||||
|
||||
|
||||
def test_hello_and_index(client):
|
||||
assert client.get("/").json() == {"message": "hello"}
|
||||
assert client.get("/api").json() == {
|
||||
"characters": f"{API}/character",
|
||||
"locations": f"{API}/location",
|
||||
"episodes": f"{API}/episode",
|
||||
}
|
||||
assert client.get("/api/").status_code == 200
|
||||
assert client.get("/health").json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_character_same_format_as_original(seeded):
|
||||
rick = seeded.get("/api/character/1").json()
|
||||
assert list(rick) == [
|
||||
"id",
|
||||
"name",
|
||||
"status",
|
||||
"species",
|
||||
"type",
|
||||
"gender",
|
||||
"origin",
|
||||
"location",
|
||||
"image",
|
||||
"episode",
|
||||
"url",
|
||||
"created",
|
||||
]
|
||||
assert rick["origin"] == {"name": "Earth (C-137)", "url": f"{API}/location/1"}
|
||||
assert rick["location"] == {"name": "Citadel of Ricks", "url": f"{API}/location/3"}
|
||||
assert rick["episode"] == [f"{API}/episode/1", f"{API}/episode/2"]
|
||||
assert rick["url"] == f"{API}/character/1"
|
||||
assert rick["created"] == "2017-11-04T18:48:46.250Z"
|
||||
morty = seeded.get("/api/character/2").json()
|
||||
assert morty["origin"] == {"name": "unknown", "url": ""}
|
||||
|
||||
|
||||
def test_residents_are_stored_not_derived(seeded):
|
||||
# Rick est « à » la Citadelle mais n'en fait pas partie des residents saisis
|
||||
citadel = seeded.get("/api/location/3").json()
|
||||
assert list(citadel) == ["id", "name", "type", "dimension", "residents", "url", "created"]
|
||||
assert citadel["residents"] == [f"{API}/character/2", f"{API}/character/8"]
|
||||
|
||||
|
||||
def test_episode_characters_follow_character_episode(seeded):
|
||||
pilot = seeded.get("/api/episode/1").json()
|
||||
assert list(pilot) == ["id", "name", "air_date", "episode", "characters", "url", "created"]
|
||||
assert pilot["characters"] == [f"{API}/character/1", f"{API}/character/2"]
|
||||
assert pilot["air_date"] == "December 2, 2013"
|
||||
|
||||
|
||||
def test_multiple_ids(seeded):
|
||||
assert [c["id"] for c in seeded.get("/api/character/1,3").json()] == [1, 3]
|
||||
assert [c["id"] for c in seeded.get("/api/character/[2,1]").json()] == [1, 2]
|
||||
assert seeded.get("/api/character/1,99").status_code == 200
|
||||
|
||||
|
||||
def test_errors_use_original_messages(seeded):
|
||||
assert seeded.get("/api/character/99").json() == {"error": "Character not found"}
|
||||
assert seeded.get("/api/location/99").json() == {"error": "Location not found"}
|
||||
assert seeded.get("/api/episode/99").json() == {"error": "Episode not found"}
|
||||
r = seeded.get("/api/character/abc")
|
||||
assert r.status_code == 400
|
||||
assert r.json() == {"error": "Hey! you must provide an id"}
|
||||
assert seeded.get("/api/character/[1,a]").json() == {"error": "Bad... bad array :/"}
|
||||
r = seeded.get("/api/nothing")
|
||||
assert r.status_code == 404
|
||||
assert r.json() == {"error": "There is nothing here"}
|
||||
|
||||
|
||||
def test_filters(seeded):
|
||||
names = lambda q: [c["name"] for c in seeded.get(f"/api/character?{q}").json()["results"]] # noqa: E731
|
||||
assert names("name=smith") == ["Morty Smith", "Summer Smith"]
|
||||
assert names("gender=male") == ["Rick Sanchez", "Morty Smith"]
|
||||
assert names("gender=female") == ["Summer Smith"]
|
||||
assert names("status=ALIVE&name=rick") == ["Rick Sanchez"]
|
||||
assert seeded.get("/api/character?name=zzz").status_code == 404
|
||||
locs = seeded.get("/api/location?type=space").json()["results"]
|
||||
assert [x["name"] for x in locs] == ["Citadel of Ricks"]
|
||||
eps = seeded.get("/api/episode?episode=s01e02").json()["results"]
|
||||
assert [x["name"] for x in eps] == ["Lawnmower Dog"]
|
||||
|
||||
|
||||
def test_pagination(client):
|
||||
for i in range(1, 26):
|
||||
client.post("/api/location", json={"name": f"Planet {i}"})
|
||||
page1 = client.get("/api/location?name=planet").json()
|
||||
assert page1["info"] == {
|
||||
"count": 25,
|
||||
"pages": 2,
|
||||
"next": f"{API}/location?page=2&name=planet",
|
||||
"prev": None,
|
||||
}
|
||||
assert len(page1["results"]) == 20
|
||||
page2 = client.get("/api/location?page=2&name=planet").json()
|
||||
assert page2["info"]["prev"] == f"{API}/location?page=1&name=planet"
|
||||
assert len(page2["results"]) == 5
|
||||
assert client.get("/api/location?page=abc").json()["info"]["count"] == 25
|
||||
assert client.get("/api/location?page=3").status_code == 404
|
||||
@@ -0,0 +1,70 @@
|
||||
from tests.conftest import ADMIN, SRC
|
||||
|
||||
|
||||
def test_post_duplicate_id_conflicts(seeded):
|
||||
r = seeded.post("/api/location", json={"id": 1, "name": "Doublon"})
|
||||
assert r.status_code == 409
|
||||
assert "error" in r.json()
|
||||
|
||||
|
||||
def test_post_without_id_gets_next_id(seeded):
|
||||
r = seeded.post("/api/location", json={"name": "Nouvelle"})
|
||||
assert r.json()["id"] == 4
|
||||
|
||||
|
||||
def test_character_validation(seeded):
|
||||
base = {"name": "X", "status": "Alive", "gender": "Male"}
|
||||
assert seeded.post("/api/character", json=base | {"status": "alive"}).status_code == 422
|
||||
bad_url = base | {"origin": {"name": "x", "url": f"{SRC}/episode/1"}}
|
||||
assert seeded.post("/api/character", json=bad_url).status_code == 422
|
||||
missing = base | {"episode": [f"{SRC}/episode/99"]}
|
||||
r = seeded.post("/api/character", json=missing)
|
||||
assert r.status_code == 422
|
||||
assert "99" in r.json()["error"]
|
||||
|
||||
|
||||
def test_episode_code(seeded):
|
||||
assert seeded.post("/api/episode", json={"name": "B", "episode": "1x01"}).status_code == 422
|
||||
assert seeded.post("/api/episode", json={"name": "B", "episode": "S01E01"}).status_code == 409
|
||||
|
||||
|
||||
def test_put_moves_character(seeded):
|
||||
rick = seeded.get("/api/character/1").json()
|
||||
rick["location"] = {"name": "Earth (C-137)", "url": f"{SRC}/location/1"}
|
||||
r = seeded.put("/api/character/1", json=rick)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["created"] == "2017-11-04T18:48:46.250Z"
|
||||
assert r.json()["location"]["name"] == "Earth (C-137)"
|
||||
assert seeded.put("/api/character/99", json=rick).status_code == 404
|
||||
|
||||
|
||||
def test_put_location_replaces_residents(seeded):
|
||||
body = seeded.get("/api/location/3").json()
|
||||
body["residents"] = [f"{SRC}/character/8", f"{SRC}/character/1"]
|
||||
r = seeded.put("/api/location/3", json=body)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["residents"] == [ # ordre de saisie conservé, comme l'original
|
||||
"https://api.test/api/character/8",
|
||||
"https://api.test/api/character/1",
|
||||
]
|
||||
assert seeded.get("/api/location/3").json()["residents"] == r.json()["residents"]
|
||||
body["residents"] = [f"{SRC}/location/1"]
|
||||
assert seeded.put("/api/location/3", json=body).status_code == 422
|
||||
|
||||
|
||||
def test_delete_is_protected(seeded):
|
||||
assert seeded.delete("/api/character/1").status_code == 401
|
||||
assert seeded.delete("/api/character/1", headers={"X-API-Key": "bad"}).status_code == 401
|
||||
assert seeded.delete("/api/character/1", headers=ADMIN).status_code == 204
|
||||
assert seeded.get("/api/character/1").status_code == 404
|
||||
assert seeded.get("/api/episode/1").json()["characters"] == ["https://api.test/api/character/2"]
|
||||
|
||||
|
||||
def test_delete_location_sets_unknown(seeded):
|
||||
assert seeded.delete("/api/location/3", headers=ADMIN).status_code == 204
|
||||
assert seeded.get("/api/character/1").json()["location"] == {"name": "unknown", "url": ""}
|
||||
|
||||
|
||||
def test_delete_episode_unlinks(seeded):
|
||||
assert seeded.delete("/api/episode/2", headers=ADMIN).status_code == 204
|
||||
assert seeded.get("/api/character/1").json()["episode"] == ["https://api.test/api/episode/1"]
|
||||
Reference in new issue
Block a user