Improved API models and swagger documentation
authorAdam Dullage <redacted>
Tue, 29 Aug 2023 11:36:10 +0000 (12:36 +0100)
committerAdam Dullage <redacted>
Tue, 29 Aug 2023 11:36:10 +0000 (12:36 +0100)
flatnotes/helpers.py
flatnotes/main.py
flatnotes/models.py

index 24cbf0630a9d4e3b38de07e4f7e38b38f097d14f..56bd4f345dbbd4ce71df65b2e75c698d936c364e 100644 (file)
@@ -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
index f186f608f0f7a42ce01684147bdca785c57c2748..7e3345285556a8891921c830e9e083cd5345309d 100644 (file)
@@ -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")
index 265c9b504fbda83e2d06b7c0c1da0012f5a5ba83..07d3d38de95b719ea4d6bc293d8ca90f6acb57b8 100644 (file)
@@ -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
git clone https://git.99rst.org/PROJECT