first
build / build (push) Successful in 53s

This commit is contained in:
hcornet committed 2026-09-21 17:43:42 +02:00
1 parent 8405c0b1f9
commit ea531c4e1c
29 files changed
+1453 -414

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
.git
.gitea
.env
data
*.db
__pycache__
.pytest_cache
.ruff_cache
.venv
docker-compose.yml
README.md
+7
View File
@@ -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
+62
View File
@@ -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
View File
@@ -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
View File
@@ -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", "*"]
+52
View File
@@ -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
```
+3
View File
@@ -0,0 +1,3 @@
import os
__version__ = os.getenv("APP_VERSION", "dev")
+20
View File
@@ -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()
+37
View File
@@ -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()
+33
View File
@@ -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
View File
@@ -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__}
+98
View File
@@ -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é
View File
Whitespace-only changes.
+126
View File
@@ -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)
+133
View File
@@ -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]
+88
View File
@@ -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)
+95
View File
@@ -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
View File
@@ -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
+24
View File
@@ -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"},
)
+63
View File
@@ -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
View File
@@ -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))
+31
View File
@@ -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
+17
View File
@@ -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"]
+4
View File
@@ -0,0 +1,4 @@
-r requirements.txt
pytest==9.1.1
httpx==0.28.1
ruff==0.16.8
+5
View File
@@ -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
View File
Whitespace-only changes.
+124
View File
@@ -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
+101
View File
@@ -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
+70
View File
@@ -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"]