-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapi.py
More file actions
193 lines (156 loc) · 6.34 KB
/
api.py
File metadata and controls
193 lines (156 loc) · 6.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
"""
FastAPI REST API for the Library Management System.
Provides endpoints for CRUD operations on books and integration
with the OpenLibrary API for fetching book metadata.
"""
import logging
from contextlib import asynccontextmanager
from typing import List, Optional
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, field_validator
import config
from library import Library, Book as LibraryBook
from main import get_book_details_from_openlibrary
from validators import validate_isbn, validate_year, ValidationError
# Configure logging
logging.basicConfig(
level=getattr(logging, config.LOG_LEVEL.upper(), logging.INFO),
format=config.LOG_FORMAT,
handlers=[
logging.StreamHandler(),
*([logging.FileHandler(config.LOG_FILE)] if config.LOG_FILE else [])
]
)
logger = logging.getLogger(__name__)
# This dictionary will hold our single Library instance.
app_state = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage application lifecycle - startup and shutdown."""
logger.info("Server starting up...")
# Create the single, shared Library instance and store it in the app_state.
app_state["library"] = Library(config.LIBRARY_FILE)
logger.info(f"Library loaded with {len(app_state['library'].books)} books.")
yield
# This code runs when the application is shutting down.
logger.info("Server shutting down...")
app_state["library"].save_books()
logger.info("Library data saved.")
app = FastAPI(
title="Library API",
description="A simple API to manage a library of books.",
version="1.0.0",
lifespan=lifespan
)
# Add CORS middleware with configurable origins
app.add_middleware(
CORSMiddleware,
allow_origins=config.CORS_ORIGINS,
allow_credentials=config.CORS_ALLOW_CREDENTIALS,
allow_methods=config.CORS_ALLOW_METHODS,
allow_headers=config.CORS_ALLOW_HEADERS,
)
logger.info(f"CORS configured with origins: {config.CORS_ORIGINS}")
class Book(BaseModel):
"""Pydantic model for a book, used for API request and response validation."""
title: str
author: str
isbn: str
year: int
available: bool = True
date_added: Optional[str] = None
@field_validator('isbn')
@classmethod
def validate_isbn_format(cls, v: str) -> str:
"""Validate ISBN format (ISBN-10 or ISBN-13)."""
try:
validate_isbn(v)
except ValidationError as e:
raise ValueError(str(e))
return v
@field_validator('year')
@classmethod
def validate_year_range(cls, v: int) -> int:
"""Validate publication year is within reasonable range."""
try:
validate_year(v)
except ValidationError as e:
raise ValueError(str(e))
return v
@field_validator('title', 'author')
@classmethod
def validate_not_empty(cls, v: str, info) -> str:
"""Validate that title and author are not empty."""
if not v or not v.strip():
raise ValueError(f"{info.field_name} cannot be empty")
return v.strip()
@app.get("/health")
async def health_check():
"""Health check endpoint for container orchestration."""
return {
"status": "healthy",
"books_count": len(app_state.get("library", Library()).books)
}
@app.get("/books", response_model=List[Book])
async def list_all_books():
"""Retrieve a list of all books in the library."""
library = app_state["library"]
logger.debug(f"Listing all {len(library.books)} books")
return [book.to_dict() for book in library.books]
@app.get("/books/{isbn}", response_model=Book)
async def get_single_book(isbn: str):
"""Retrieve a single book by its ISBN."""
library = app_state["library"]
book = library.find_book(isbn)
if book is None:
logger.warning(f"Book not found: ISBN={isbn}")
raise HTTPException(status_code=404, detail="Book not found")
logger.debug(f"Retrieved book: {book.title} (ISBN={isbn})")
return book.to_dict()
@app.get("/openlibrary/{isbn}")
async def fetch_openlibrary_info(isbn: str):
"""Fetch book details from OpenLibrary without adding to the library."""
logger.info(f"Fetching book details from OpenLibrary: ISBN={isbn}")
book_data = get_book_details_from_openlibrary(isbn)
if not book_data:
logger.warning(f"Book not found on OpenLibrary: ISBN={isbn}")
raise HTTPException(status_code=404, detail="Book not found on OpenLibrary.")
logger.info(f"Found book on OpenLibrary: {book_data.get('title', 'Unknown')}")
return book_data
@app.post("/books", response_model=Book, status_code=201)
async def add_new_book(book: Book):
"""Add a new book to the library."""
library = app_state["library"]
if any(b.isbn == book.isbn for b in library.books):
logger.warning(f"Attempted to add duplicate book: ISBN={book.isbn}")
raise HTTPException(status_code=400, detail="Book with this ISBN already exists")
new_book = LibraryBook(**book.model_dump(exclude_none=True))
library.add_book(new_book)
logger.info(f"Added new book: {book.title} (ISBN={book.isbn})")
# Find the book again to get the version with the timestamp
added_book = library.find_book(new_book.isbn)
return added_book.to_dict()
@app.put("/books/{isbn}", response_model=Book)
async def update_existing_book(isbn: str, updated_book: Book):
"""Update an existing book's details."""
library = app_state["library"]
book_to_update = library.find_book(isbn)
if book_to_update is None:
logger.warning(f"Attempted to update non-existent book: ISBN={isbn}")
raise HTTPException(status_code=404, detail="Book not found")
update_data = updated_book.model_dump(exclude={'isbn'}, exclude_none=True)
library.update_book(isbn, **update_data)
logger.info(f"Updated book: ISBN={isbn}")
updated_book_data = library.find_book(isbn)
return updated_book_data.to_dict()
@app.delete("/books/{isbn}", status_code=204)
async def remove_existing_book(isbn: str):
"""Remove a book from the library."""
library = app_state["library"]
if not library.find_book(isbn):
logger.warning(f"Attempted to remove non-existent book: ISBN={isbn}")
raise HTTPException(status_code=404, detail="Book not found")
library.remove_book(isbn)
logger.info(f"Removed book: ISBN={isbn}")
return {}