From c38187a64ea9c379b192428537c596762ef2b5c6 Mon Sep 17 00:00:00 2001 From: Jack Jackson Date: Wed, 24 Jan 2024 20:53:27 -0800 Subject: [PATCH] Add OpenAPI definition and Swagger UI --- NOTES.md | 12 ++- app/__init__.py | 3 + app/main.py | 194 ++++++++++++++++++++++++++++++++++- tests/test_fresh_db_tests.py | 8 +- 4 files changed, 209 insertions(+), 8 deletions(-) diff --git a/NOTES.md b/NOTES.md index 5c55052..4d4ee94 100644 --- a/NOTES.md +++ b/NOTES.md @@ -6,8 +6,9 @@ - [X] Figure out how to return JSON or html (`render_template`) - [X] Basic testing - [X] ruff -- [ ] GitHub Actions for tests and linters -- [ ] Swagger API +- [X] GitHub Actions for tests and linters +- [ ] Basic List pages for entities +- [X] Swagger API - [ ] Local development tool to clear/seed database ... - [ ] Authentication (will need to link `user` table to `player`) @@ -40,4 +41,9 @@ https://flask-sqlalchemy.palletsprojects.com/en/3.1.x/quickstart/#create-the-tab # Authentication -https://www.digitalocean.com/community/tutorials/how-to-add-authentication-to-your-app-with-flask-login \ No newline at end of file +https://www.digitalocean.com/community/tutorials/how-to-add-authentication-to-your-app-with-flask-login + +# Things to learn/fix/understand + +* How to use an existing Python class as the spec in a Swagger API definition? (See, for instance, `responses.200.content.application/json.schema.id` under `get_player`). Perhaps it's not possible just by name, which would be understandable (the context of a Docstring probably doesn't include the context of imported code) - but, in that case, at least I'd like to be able to specify `definitions`` in a way that is independent of a method. +* How to specify the content-type that should be sent with a Swagger request without needing to use `requestBody` (and thus, implying that there _should_ be a body)? \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index 9adedbd..fa6c9c3 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -3,12 +3,15 @@ import sys from flask import Flask from flask_sqlalchemy import SQLAlchemy +from flasgger import Swagger db = SQLAlchemy() def create_app(): app = Flask(__name__) + app.config["SWAGGER"] = {"openapi": "3.0.2"} + swagger = Swagger(app) secret_key = os.environ.get("SECRET_KEY") if not secret_key: diff --git a/app/main.py b/app/main.py index cb9f746..1062ab5 100644 --- a/app/main.py +++ b/app/main.py @@ -7,20 +7,93 @@ main = Blueprint("main", __name__) @main.route("/") def index(): + """Main Page + --- + responses: + 200: + description: A friendly greeting + schema: + type: string + """ return "Hello, World - but new!" @main.route("/player", methods=["POST"]) def create_player(): + """Create a Player + --- + requestBody: + description: Payload describing the player + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + example: Jim Bloggs + required: + - name + responses: + 201: + description: Payload containing Player Id + schema: + type: object + properties: + id: + type: number + required: + - id + tags: + - player + """ data = request.json player = Player(name=data["name"]) db.session.add(player) db.session.commit() - return {"id": player.id} + return {"id": player.id}, 201 @main.route("/player/") def get_player(player_id: str): + """Get a Player + --- + parameters: + - name: player_id + in: path + required: true + schema: + type: integer + minimum: 1 + description: The Player Id + requestBody: + content: + application/json: + schema: {} + text/html: + schema: {} + responses: + 200: + description: Payload describing player + content: + application/json: + schema: + id: Player + text/html: + schema: + type: string + 404: + description: Player not found + content: + application/json: {} + text/html: {} + tags: + - player + """ + # TODO - actually, the schema above doesn't reference the `Player` class as I'd hoped it would. + # The docs at https://github.com/flasgger/flasgger#extracting-definitions are not super-clear. + # _Maybe_ what I'm trying to do is not possible? player_from_db = db.session.get(Player, int(player_id)) if not player_from_db: return "Not Found", 404 @@ -36,6 +109,29 @@ def get_player(player_id: str): @main.route("/player/", methods=["DELETE"]) def delete_player(player_id: str): + """Delete a Player + --- + parameters: + - name: player_id + in: path + required: true + schema: + type: integer + minimum: 1 + description: The Player Id + requestBody: + content: + application/json: + schema: {} + responses: + 204: + description: Empty + content: + application/json: + schema: {} + tags: + - player + """ # Note - no checking that the player exists, because HTTP semantics specify # that `DELETE` should be idempotent. db.session.query(Player).filter(Player.id == int(player_id)).delete() @@ -45,6 +141,43 @@ def delete_player(player_id: str): @main.route("/deck", methods=["POST"]) def create_deck(): + """Create a Deck + --- + requestBody: + description: Payload describing the deck + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + example: My First Deck + description: + type: string + example: Better than yours! + owner_id: + type: number + example: 1 + required: + - name + - owner_id + responses: + 201: + description: Payload containing Deck Id + schema: + type: object + properties: + id: + type: number + required: + - id + 400: + description: Owner not found + tags: + - deck + """ data = request.json owner_id = data["owner_id"] @@ -58,11 +191,45 @@ def create_deck(): db.session.add(deck) db.session.commit() print("Finished creating the deck!") - return {"id": deck.id} + return {"id": deck.id}, 201 @main.route("/deck/") def get_deck(deck_id: str): + """Get a Deck + --- + parameters: + - name: deck_id + in: path + required: true + schema: + type: integer + minimum: 1 + description: The Deck Id + requestBody: + content: + application/json: + schema: {} + text/html: + schema: {} + responses: + 200: + description: Payload describing deck + content: + application/json: + schema: + id: Deck + text/html: + schema: + type: string + 404: + description: Deck not found + content: + application/json: {} + text/html: {} + tags: + - deck + """ deck_from_db = db.session.get(Deck, int(deck_id)) if not deck_from_db: return "Not Found", 404 @@ -81,6 +248,29 @@ def get_deck(deck_id: str): @main.route("/deck/", methods=["DELETE"]) def delete_deck(deck_id: str): + """Delete a Deck + --- + parameters: + - name: deck_id + in: path + required: true + schema: + type: integer + minimum: 1 + description: The Deck Id + requestBody: + content: + application/json: + schema: {} + responses: + 204: + description: Empty + content: + application/json: + schema: {} + tags: + - deck + """ db.session.query(Deck).filter(Deck.id == int(deck_id)).delete() db.session.commit() return "", 204 diff --git a/tests/test_fresh_db_tests.py b/tests/test_fresh_db_tests.py index 1a7641e..409cb05 100644 --- a/tests/test_fresh_db_tests.py +++ b/tests/test_fresh_db_tests.py @@ -12,7 +12,9 @@ def test_add_and_retrieve_player(client: FlaskClient): response = _json_get(client, "/player/1") assert response.status_code == 404 - _json_post(client, "/player", {"name": "jason"}) + create_player_response = _json_post(client, "/player", {"name": "jason"}) + assert create_player_response.status_code == 201 + response_1 = _json_get(client, "/player/1") assert response_1.json["name"] == "jason" @@ -34,13 +36,13 @@ def test_add_and_retrieve_deck(client: FlaskClient): assert invalid_owner_response.text == "Owner id 1 not found" create_jim_response = _json_post(client, "/player", {"name": "jim"}) - assert create_jim_response.status_code == 200 + assert create_jim_response.status_code == 201 jim_id = create_jim_response.json["id"] create_deck_response = _json_post( client, "/deck", {"name": "Baby's First Deck", "owner_id": str(jim_id)} ) - assert create_deck_response.status_code == 200 + assert create_deck_response.status_code == 201 # _Should_ always be 1, since we expect to start with an empty database, but why risk it? deck_id = create_deck_response.json["id"]