Add OpenAPI definition and Swagger UI

This commit is contained in:
Jack Jackson 2024-01-24 20:53:27 -08:00
parent ada7473610
commit c38187a64e
4 changed files with 209 additions and 8 deletions

View File

@ -6,8 +6,9 @@
- [X] Figure out how to return JSON or html (`render_template`) - [X] Figure out how to return JSON or html (`render_template`)
- [X] Basic testing - [X] Basic testing
- [X] ruff - [X] ruff
- [ ] GitHub Actions for tests and linters - [X] GitHub Actions for tests and linters
- [ ] Swagger API - [ ] Basic List pages for entities
- [X] Swagger API
- [ ] Local development tool to clear/seed database - [ ] Local development tool to clear/seed database
... ...
- [ ] Authentication (will need to link `user` table to `player`) - [ ] 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 # Authentication
https://www.digitalocean.com/community/tutorials/how-to-add-authentication-to-your-app-with-flask-login 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)?

View File

@ -3,12 +3,15 @@ import sys
from flask import Flask from flask import Flask
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flasgger import Swagger
db = SQLAlchemy() db = SQLAlchemy()
def create_app(): def create_app():
app = Flask(__name__) app = Flask(__name__)
app.config["SWAGGER"] = {"openapi": "3.0.2"}
swagger = Swagger(app)
secret_key = os.environ.get("SECRET_KEY") secret_key = os.environ.get("SECRET_KEY")
if not secret_key: if not secret_key:

View File

@ -7,20 +7,93 @@ main = Blueprint("main", __name__)
@main.route("/") @main.route("/")
def index(): def index():
"""Main Page
---
responses:
200:
description: A friendly greeting
schema:
type: string
"""
return "Hello, World - but new!" return "Hello, World - but new!"
@main.route("/player", methods=["POST"]) @main.route("/player", methods=["POST"])
def create_player(): 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 data = request.json
player = Player(name=data["name"]) player = Player(name=data["name"])
db.session.add(player) db.session.add(player)
db.session.commit() db.session.commit()
return {"id": player.id} return {"id": player.id}, 201
@main.route("/player/<player_id>") @main.route("/player/<player_id>")
def get_player(player_id: str): 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)) player_from_db = db.session.get(Player, int(player_id))
if not player_from_db: if not player_from_db:
return "Not Found", 404 return "Not Found", 404
@ -36,6 +109,29 @@ def get_player(player_id: str):
@main.route("/player/<player_id>", methods=["DELETE"]) @main.route("/player/<player_id>", methods=["DELETE"])
def delete_player(player_id: str): 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 # Note - no checking that the player exists, because HTTP semantics specify
# that `DELETE` should be idempotent. # that `DELETE` should be idempotent.
db.session.query(Player).filter(Player.id == int(player_id)).delete() 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"]) @main.route("/deck", methods=["POST"])
def create_deck(): 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 data = request.json
owner_id = data["owner_id"] owner_id = data["owner_id"]
@ -58,11 +191,45 @@ def create_deck():
db.session.add(deck) db.session.add(deck)
db.session.commit() db.session.commit()
print("Finished creating the deck!") print("Finished creating the deck!")
return {"id": deck.id} return {"id": deck.id}, 201
@main.route("/deck/<deck_id>") @main.route("/deck/<deck_id>")
def get_deck(deck_id: str): 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)) deck_from_db = db.session.get(Deck, int(deck_id))
if not deck_from_db: if not deck_from_db:
return "Not Found", 404 return "Not Found", 404
@ -81,6 +248,29 @@ def get_deck(deck_id: str):
@main.route("/deck/<deck_id>", methods=["DELETE"]) @main.route("/deck/<deck_id>", methods=["DELETE"])
def delete_deck(deck_id: str): 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.query(Deck).filter(Deck.id == int(deck_id)).delete()
db.session.commit() db.session.commit()
return "", 204 return "", 204

View File

@ -12,7 +12,9 @@ def test_add_and_retrieve_player(client: FlaskClient):
response = _json_get(client, "/player/1") response = _json_get(client, "/player/1")
assert response.status_code == 404 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") response_1 = _json_get(client, "/player/1")
assert response_1.json["name"] == "jason" 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" assert invalid_owner_response.text == "Owner id 1 not found"
create_jim_response = _json_post(client, "/player", {"name": "jim"}) 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"] jim_id = create_jim_response.json["id"]
create_deck_response = _json_post( create_deck_response = _json_post(
client, "/deck", {"name": "Baby's First Deck", "owner_id": str(jim_id)} 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? # _Should_ always be 1, since we expect to start with an empty database, but why risk it?
deck_id = create_deck_response.json["id"] deck_id = create_deck_response.json["id"]