From: Adam Dullage Date: Tue, 29 Aug 2023 11:36:10 +0000 (+0100) Subject: Improved API models and swagger documentation X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=724d8044c598610e6e39c6857fe57d57a5e9333a;p=flatnotes.git Improved API models and swagger documentation --- diff --git a/flatnotes/helpers.py b/flatnotes/helpers.py index 24cbf06..56bd4f3 100644 --- a/flatnotes/helpers.py +++ b/flatnotes/helpers.py @@ -3,8 +3,6 @@ import re import shutil from typing import List, Tuple -from pydantic import BaseModel - def strip_ext(filename): return os.path.splitext(filename)[0] @@ -33,8 +31,3 @@ def re_extract(pattern, string) -> Tuple[str, List[str]]: matches = [] text = re.sub(pattern, lambda tag: matches.append(tag.group()), string) return (text, matches) - - -class CamelCaseBaseModel(BaseModel): - class Config: - alias_generator = camel_case diff --git a/flatnotes/main.py b/flatnotes/main.py index f186f60..7e33452 100644 --- a/flatnotes/main.py +++ b/flatnotes/main.py @@ -1,5 +1,5 @@ import secrets -from typing import List, Literal +from typing import List, Literal, Union import pyotp from fastapi import Depends, FastAPI, HTTPException @@ -18,9 +18,12 @@ from flatnotes import Flatnotes, InvalidTitleError, Note from models import ( ConfigModel, LoginModel, - NoteModel, + NoteResponseModel, + NoteContentResponseModel, NotePatchModel, + NotePostModel, SearchResultModel, + TokenModel, ) app = FastAPI() @@ -50,7 +53,7 @@ if config.auth_type == AuthType.TOTP: if config.auth_type not in [AuthType.NONE, AuthType.READ_ONLY]: - @app.post("/api/token") + @app.post("/api/token", response_model=TokenModel) def token(data: LoginModel): global last_used_totp @@ -82,14 +85,14 @@ if config.auth_type not in [AuthType.NONE, AuthType.READ_ONLY]: access_token = create_access_token(data={"sub": config.username}) if config.auth_type == AuthType.TOTP: last_used_totp = current_totp - return {"access_token": access_token, "token_type": "bearer"} + return TokenModel(access_token=access_token) -@app.get("/") -@app.get("/login") -@app.get("/search") -@app.get("/new") -@app.get("/note/{title}") +@app.get("/", include_in_schema=False) +@app.get("/login", include_in_schema=False) +@app.get("/search", include_in_schema=False) +@app.get("/new", include_in_schema=False) +@app.get("/note/{title}", include_in_schema=False) def root(title: str = ""): with open("flatnotes/dist/index.html", "r", encoding="utf-8") as f: html = f.read() @@ -101,14 +104,14 @@ if config.auth_type != AuthType.READ_ONLY: @app.post( "/api/notes", dependencies=[Depends(authenticate)], - response_model=NoteModel, + response_model=NoteContentResponseModel, ) - def post_note(data: NoteModel): + def post_note(data: NotePostModel): """Create a new note.""" try: note = Note(flatnotes, data.title, new=True) note.content = data.content - return NoteModel.dump(note, include_content=True) + return NoteContentResponseModel.model_validate(note) except InvalidTitleError: return invalid_title_response except FileExistsError: @@ -118,7 +121,7 @@ if config.auth_type != AuthType.READ_ONLY: @app.get( "/api/notes/{title}", dependencies=[Depends(authenticate)], - response_model=NoteModel, + response_model=Union[NoteContentResponseModel, NoteResponseModel], ) def get_note( title: str, @@ -127,7 +130,10 @@ def get_note( """Get a specific note.""" try: note = Note(flatnotes, title) - return NoteModel.dump(note, include_content=include_content) + if include_content: + return NoteContentResponseModel.model_validate(note) + else: + return NoteResponseModel.model_validate(note) except InvalidTitleError: return invalid_title_response except FileNotFoundError: @@ -139,7 +145,7 @@ if config.auth_type != AuthType.READ_ONLY: @app.patch( "/api/notes/{title}", dependencies=[Depends(authenticate)], - response_model=NoteModel, + response_model=NoteContentResponseModel, ) def patch_note(title: str, new_data: NotePatchModel): try: @@ -148,7 +154,7 @@ if config.auth_type != AuthType.READ_ONLY: note.title = new_data.new_title if new_data.new_content is not None: note.content = new_data.new_content - return NoteModel.dump(note, include_content=True) + return NoteContentResponseModel.model_validate(note) except InvalidTitleError: return invalid_title_response except FileExistsError: @@ -159,7 +165,11 @@ if config.auth_type != AuthType.READ_ONLY: if config.auth_type != AuthType.READ_ONLY: - @app.delete("/api/notes/{title}", dependencies=[Depends(authenticate)]) + @app.delete( + "/api/notes/{title}", + dependencies=[Depends(authenticate)], + response_model=None, + ) def delete_note(title: str): try: note = Note(flatnotes, title) @@ -173,6 +183,7 @@ if config.auth_type != AuthType.READ_ONLY: @app.get( "/api/tags", dependencies=[Depends(authenticate)], + response_model=List[str], ) def get_tags(): """Get a list of all indexed tags.""" @@ -194,7 +205,7 @@ def search( if sort == "lastModified": sort = "last_modified" return [ - SearchResultModel.dump(note_hit) + SearchResultModel.model_validate(note_hit) for note_hit in flatnotes.search( term, sort=sort, order=order, limit=limit ) @@ -204,7 +215,7 @@ def search( @app.get("/api/config", response_model=ConfigModel) def get_config(): """Retrieve server-side config required for the UI.""" - return ConfigModel.dump(config) + return ConfigModel.model_validate(config) app.mount("/", StaticFiles(directory="flatnotes/dist"), name="dist") diff --git a/flatnotes/models.py b/flatnotes/models.py index 265c9b5..07d3d38 100644 --- a/flatnotes/models.py +++ b/flatnotes/models.py @@ -1,59 +1,57 @@ -from typing import Dict, List, Optional +from typing import List, Optional -from config import AuthType, Config -from flatnotes import Note, SearchResult -from helpers import CamelCaseBaseModel +from pydantic import BaseModel, Field +from config import AuthType +from helpers import camel_case -class LoginModel(CamelCaseBaseModel): + +class TokenModel(BaseModel): + # Use of BaseModel instead of CustomBaseModel is intentional as OAuth + # requires keys to be snake_case + access_token: str + token_type: str = Field("bearer") + + +class CustomBaseModel(BaseModel): + class Config: + alias_generator = camel_case + populate_by_name = True + from_attributes = True + + +class LoginModel(CustomBaseModel): username: str password: str -class NoteModel(CamelCaseBaseModel): +class NotePostModel(CustomBaseModel): title: str - last_modified: Optional[int] - content: Optional[str] + content: Optional[str] = Field(None) - @classmethod - def dump(cls, note: Note, include_content: bool = True) -> Dict: - return { - "title": note.title, - "lastModified": note.last_modified, - "content": note.content if include_content else None, - } +class NoteResponseModel(CustomBaseModel): + title: str + last_modified: int -class NotePatchModel(CamelCaseBaseModel): - new_title: Optional[str] - new_content: Optional[str] +class NoteContentResponseModel(NoteResponseModel): + content: Optional[str] = Field(None) -class SearchResultModel(CamelCaseBaseModel): - score: Optional[float] + +class NotePatchModel(CustomBaseModel): + new_title: Optional[str] = Field(None) + new_content: Optional[str] = Field(None) + + +class SearchResultModel(CustomBaseModel): + score: Optional[float] = Field(None) title: str last_modified: int - title_highlights: Optional[str] - content_highlights: Optional[str] - tag_matches: Optional[List[str]] - - @classmethod - def dump(self, search_result: SearchResult) -> Dict: - return { - "score": search_result.score, - "title": search_result.title, - "lastModified": search_result.last_modified, - "titleHighlights": search_result.title_highlights, - "contentHighlights": search_result.content_highlights, - "tagMatches": search_result.tag_matches, - } - - -class ConfigModel(CamelCaseBaseModel): - auth_type: AuthType + title_highlights: Optional[str] = Field(None) + content_highlights: Optional[str] = Field(None) + tag_matches: Optional[List[str]] = Field(None) + - @classmethod - def dump(self, config: Config) -> Dict: - return { - "authType": config.auth_type.value, - } +class ConfigModel(CustomBaseModel): + auth_type: AuthType