Refactor OpenAPI client script and backend server logic.

Simplify and streamline OpenAPI client generation and backend startup logic. Improved error handling, environment configuration, and self-signed SSL certificate management. Added support for generating OpenAPI schema via command-line argument.
This commit is contained in:
GotthardG 2024-12-17 14:50:31 +01:00
parent 176aaa2867
commit 33e3a2d4df
2 changed files with 56 additions and 86 deletions

View File

@ -1,4 +1,3 @@
# app/main.py
import os import os
import json import json
import tomllib import tomllib
@ -6,8 +5,6 @@ from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app import ssl_heidi from app import ssl_heidi
from app.routers import ( from app.routers import (
address, address,
contact, contact,
@ -25,7 +22,6 @@ from app.database import Base, engine, SessionLocal, load_sample_data, load_slot
# Utility function to fetch metadata from pyproject.toml # Utility function to fetch metadata from pyproject.toml
def get_project_metadata(): def get_project_metadata():
# Start from the current script's directory and search for pyproject.toml
script_dir = Path(__file__).resolve().parent script_dir = Path(__file__).resolve().parent
for parent in script_dir.parents: for parent in script_dir.parents:
pyproject_path = parent / "pyproject.toml" pyproject_path = parent / "pyproject.toml"
@ -35,8 +31,6 @@ def get_project_metadata():
name = pyproject["project"]["name"] name = pyproject["project"]["name"]
version = pyproject["project"]["version"] version = pyproject["project"]["version"]
return name, version return name, version
# If no pyproject.toml is found, raise FileNotFoundError
raise FileNotFoundError( raise FileNotFoundError(
f"pyproject.toml not found in any parent directory of {script_dir}" f"pyproject.toml not found in any parent directory of {script_dir}"
) )
@ -45,21 +39,24 @@ def get_project_metadata():
# Get project metadata from pyproject.toml # Get project metadata from pyproject.toml
project_name, project_version = get_project_metadata() project_name, project_version = get_project_metadata()
app = FastAPI( app = FastAPI(
title=project_name, # Syncs with project `name` title=project_name,
description="Backend for next-gen sample management system", description="Backend for next-gen sample management system",
version=project_version, # Syncs with project `version` version=project_version,
) )
# Determine environment and configuration file path # Determine environment and configuration file path
environment = os.getenv("ENVIRONMENT", "dev") environment = os.getenv("ENVIRONMENT", "dev")
config_file = Path(__file__).resolve().parent.parent / f"config_{environment}.json" config_file = Path(__file__).resolve().parent.parent / f"config_{environment}.json"
if not config_file.exists():
raise FileNotFoundError(f"Config file '{config_file}' does not exist.")
# Load configuration # Load configuration
with open(config_file) as f: with open(config_file) as f:
config = json.load(f) config = json.load(f)
cert_path = config["ssl_cert_path"] cert_path = config.get("ssl_cert_path", "ssl/cert.pem")
key_path = config["ssl_key_path"] key_path = config.get("ssl_key_path", "ssl/key.pem")
# Generate SSL Key and Certificate if not exist (only for development) # Generate SSL Key and Certificate if not exist (only for development)
if environment == "dev": if environment == "dev":
@ -70,7 +67,7 @@ if environment == "dev":
# Apply CORS middleware # Apply CORS middleware
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], # Enable CORS for all origins allow_origins=["*"],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@ -118,60 +115,47 @@ app.include_router(spreadsheet.router, tags=["spreadsheet"])
app.include_router(logistics.router, prefix="/logistics", tags=["logistics"]) app.include_router(logistics.router, prefix="/logistics", tags=["logistics"])
app.include_router(sample.router, prefix="/samples", tags=["samples"]) app.include_router(sample.router, prefix="/samples", tags=["samples"])
if __name__ == "__main__": if __name__ == "__main__":
import sys
import uvicorn import uvicorn
from pathlib import Path
from app import ssl_heidi
from dotenv import load_dotenv from dotenv import load_dotenv
from multiprocessing import Process
from time import sleep
# Load environment variables from .env file # Load environment variables from .env file
load_dotenv() load_dotenv()
# Fetch environment values # Check if `generate-openapi` option is passed
environment = os.getenv("ENVIRONMENT", "dev") if len(sys.argv) > 1 and sys.argv[1] == "generate-openapi":
port = int(os.getenv("PORT", 8000)) # Default to 8000 if PORT is not set from fastapi.openapi.utils import get_openapi
is_ci = (
os.getenv("CI", "false").lower() == "true"
) # Detect if running in CI environment
# Determine SSL certificate and key paths # Generate and save OpenAPI JSON file
if environment == "prod": openapi_schema = get_openapi(
# Production environment must use proper SSL cert and key paths title=app.title,
cert_path = os.getenv("VITE_SSL_CERT_PATH", "ssl/prod-cert.pem") version=app.version,
key_path = os.getenv("VITE_SSL_KEY_PATH", "ssl/prod-key.pem") description=app.description,
if not Path(cert_path).exists() or not Path(key_path).exists(): routes=app.routes,
raise FileNotFoundError(
f"Production certificates not found."
f"Make sure the following files exist:\n"
f"Certificate: {cert_path}\nKey: {key_path}"
)
host = "0.0.0.0" # Allow external traffic
print(
f"Running in production mode with provided SSL certificates:\n"
f" - Certificate: {cert_path}\n - Key: {key_path}"
) )
with open("openapi.json", "w") as f:
json.dump(openapi_schema, f, indent=4)
print("openapi.json generated successfully.")
sys.exit(0) # Exit after generating the file
elif environment in ["test", "dev"]: # Default behavior: Run the server
# Test/Development environments use self-signed certificates environment = os.getenv("ENVIRONMENT", "dev")
port = int(os.getenv("PORT", 8000))
is_ci = os.getenv("CI", "false").lower() == "true"
# Development or Test environment
if environment in ["test", "dev"]:
cert_path = "ssl/cert.pem" cert_path = "ssl/cert.pem"
key_path = "ssl/key.pem" key_path = "ssl/key.pem"
host = "127.0.0.1" # Restrict to localhost host = "127.0.0.1"
print(f"Running in {environment} mode with self-signed certificates...")
# Ensure self-signed certificates exist or generate them
Path("ssl").mkdir(parents=True, exist_ok=True)
if not Path(cert_path).exists() or not Path(key_path).exists():
print(f"Generating self-signed SSL certificate at {cert_path}...")
ssl_heidi.generate_self_signed_cert(cert_path, key_path)
else: else:
raise ValueError( cert_path = os.getenv("VITE_SSL_CERT_PATH", "ssl/prod-cert.pem")
f"Unknown environment: {environment}. " key_path = os.getenv("VITE_SSL_KEY_PATH", "ssl/prod-key.pem")
f"Must be one of 'prod', 'test', or 'dev'." host = "0.0.0.0"
)
# Function to run the server
def run_server(): def run_server():
uvicorn.run( uvicorn.run(
app, app,
@ -182,18 +166,14 @@ if __name__ == "__main__":
ssl_certfile=cert_path, ssl_certfile=cert_path,
) )
# Continuous Integration handling # Run in CI mode
if is_ci: if is_ci:
from multiprocessing import Process
from time import sleep
print("CI mode detected: Starting server in a subprocess...") print("CI mode detected: Starting server in a subprocess...")
server_process = Process(target=run_server) server_process = Process(target=run_server)
server_process.start() server_process.start()
sleep(5) # Wait 5 seconds to ensure the server starts without errors sleep(5)
server_process.terminate() # Terminate the server (test purposes) server_process.terminate()
server_process.join() # Ensure proper cleanup server_process.join()
print("CI: Server started and terminated successfully for test validation.") print("CI: Server started and terminated successfully for test validation.")
else: else:
# Run the server normally
run_server() run_server()

View File

@ -3,65 +3,55 @@
# Extract values from pyproject.toml # Extract values from pyproject.toml
PYPROJECT_FILE="$(dirname "$0")/pyproject.toml" PYPROJECT_FILE="$(dirname "$0")/pyproject.toml"
# Extract name directly and ignore newlines NAME=$(awk -F'= ' '/^name/ { print $2 }' "$PYPROJECT_FILE" | tr -d '"')
NAME=$(awk -F'=' '/^name/ { gsub(/"/, "", $2); print $2 }' "$PYPROJECT_FILE" | xargs) VERSION=$(awk -F'= ' '/^version/ { print $2 }' "$PYPROJECT_FILE" | tr -d '"')
VERSION=$(awk -F'=' '/^version/ { gsub(/"/, "", $2); print $2 }' "$PYPROJECT_FILE" | xargs)
if [[ -z "$VERSION" || -z "$NAME" ]]; then if [[ -z "$NAME" || -z "$VERSION" ]]; then
echo "Error: Could not determine version or name from pyproject.toml" echo "Error: Unable to extract name or version from pyproject.toml."
exit 1
fi
# Ensure the extracted name is valid (No spaces or unexpected characters)
if ! [[ "$NAME" =~ ^[a-zA-Z0-9_-]+$ ]]; then
echo "Error: Invalid project name detected: '$NAME'"
exit 1 exit 1
fi fi
echo "Using project name: $NAME" echo "Using project name: $NAME"
echo "Using version: $VERSION" echo "Using version: $VERSION"
# Navigate to project root # Navigate to backend directory
cd "$(dirname "$0")/backend" || exit cd "$(dirname "$0")/backend" || exit
# Generate OpenAPI JSON file # Generate OpenAPI JSON file
python3 main.py generate-openapi echo "Generating OpenAPI JSON..."
python3 -m main generate-openapi
if [[ ! -f openapi.json ]]; then if [[ ! -f openapi.json ]]; then
echo "Error: openapi.json file not generated!" echo "Error: Failed to generate openapi.json!"
exit 1 exit 1
fi fi
# Download OpenAPI generator CLI if not present # Download OpenAPI Generator CLI
OPENAPI_VERSION=7.8.0 OPENAPI_VERSION="7.8.0"
if [[ ! -f openapi-generator-cli.jar ]]; then if [[ ! -f openapi-generator-cli.jar ]]; then
wget "https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/${OPENAPI_VERSION}/openapi-generator-cli-${OPENAPI_VERSION}.jar" -O openapi-generator-cli.jar wget "https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/${OPENAPI_VERSION}/openapi-generator-cli-${OPENAPI_VERSION}.jar" -O openapi-generator-cli.jar
fi fi
# Run OpenAPI generator with synced name and version # Generate client
java -jar openapi-generator-cli.jar generate \ java -jar openapi-generator-cli.jar generate \
-i "$(pwd)/openapi.json" \ -i openapi.json \
-o python-client/ \ -o python-client/ \
-g python \ -g python \
--additional-properties=packageName="${NAME}client",projectName="${NAME}",packageVersion="${VERSION}" \ --additional-properties=packageName="${NAME}_client",projectName="${NAME}",packageVersion="${VERSION}"
--additional-properties=authorName="Guillaume Gotthard",authorEmail="guillaume.gotthard@psi.ch"
# Check if the generator succeeded
if [[ ! -d python-client ]]; then if [[ ! -d python-client ]]; then
echo "OpenAPI generator failed. Exiting." echo "Error: Failed to generate Python client."
exit 1 exit 1
fi fi
# Build Python package # Build the package
echo "Building Python package..."
cd python-client || exit cd python-client || exit
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate source .venv/bin/activate
pip install -U pip build pip install -U pip build
python -m build python3 -m build
# Verify build output
if [[ ! -d dist || -z "$(ls -A dist)" ]]; then if [[ ! -d dist || -z "$(ls -A dist)" ]]; then
echo "Error: No files generated in 'dist/'. Package build failed." echo "Error: Failed to build Python package."
exit 1 exit 1
fi fi