From 1a5f33c9dee95965eaa564d220c77d45bd6e05cf Mon Sep 17 00:00:00 2001 From: Daniel Grams Date: Sat, 9 Jan 2021 20:23:41 +0100 Subject: [PATCH] Setup Rest API with Swagger #50 --- .travis.yml | 2 +- project/__init__.py | 3 +- project/api/__init__.py | 19 ++++++++ project/api/event/schemas.py | 76 +++++++++++++---------------- project/api/event_date/__init__.py | 0 project/api/event_date/resources.py | 32 ++++++++++++ project/api/event_date/schemas.py | 36 ++++++++++++++ project/api/image/__init__.py | 0 project/api/image/resources.py | 16 ++++++ project/api/image/schemas.py | 24 +++++++++ project/api/location/__init__.py | 0 project/api/location/resources.py | 16 ++++++ project/api/location/schemas.py | 30 ++++++++++++ project/api/organization/schemas.py | 9 ++++ project/api/organizer/resources.py | 6 +-- project/api/organizer/schemas.py | 12 +++++ project/api/place/__init__.py | 0 project/api/place/resources.py | 19 ++++++++ project/api/place/schemas.py | 30 ++++++++++++ project/api/schemas.py | 39 +++++++++++++++ requirements.txt | 1 + tests/api/test_event.py | 15 +++++- tests/api/test_event_date.py | 14 ++++++ tests/api/test_image.py | 6 +++ tests/api/test_location.py | 20 ++++++++ tests/api/test_organizer.py | 4 +- tests/api/test_place.py | 6 +++ 27 files changed, 384 insertions(+), 51 deletions(-) create mode 100644 project/api/event_date/__init__.py create mode 100644 project/api/event_date/resources.py create mode 100644 project/api/event_date/schemas.py create mode 100644 project/api/image/__init__.py create mode 100644 project/api/image/resources.py create mode 100644 project/api/image/schemas.py create mode 100644 project/api/location/__init__.py create mode 100644 project/api/location/resources.py create mode 100644 project/api/location/schemas.py create mode 100644 project/api/place/__init__.py create mode 100644 project/api/place/resources.py create mode 100644 project/api/place/schemas.py create mode 100644 project/api/schemas.py create mode 100644 tests/api/test_event_date.py create mode 100644 tests/api/test_image.py create mode 100644 tests/api/test_location.py create mode 100644 tests/api/test_place.py diff --git a/.travis.yml b/.travis.yml index 9c7b602..f2cf1d1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ language: python python: - - "3.7" + - "3.8" addons: apt: packages: diff --git a/project/__init__.py b/project/__init__.py index 7fb0582..d33b5a8 100644 --- a/project/__init__.py +++ b/project/__init__.py @@ -50,12 +50,13 @@ cors = CORS(app, resources={r"/api/*": {"origins": "*"}}) # API rest_api = Api(app, "/api/v1") marshmallow = Marshmallow(app) +marshmallow_plugin = MarshmallowPlugin() app.config.update( { "APISPEC_SPEC": APISpec( title="Oveda API", version="1.0.0", - plugins=[MarshmallowPlugin()], + plugins=[marshmallow_plugin], openapi_version="2.0", ), } diff --git a/project/api/__init__.py b/project/api/__init__.py index df7f796..9f406ab 100644 --- a/project/api/__init__.py +++ b/project/api/__init__.py @@ -1,3 +1,22 @@ +def enum_to_properties(self, field, **kwargs): + """ + Add an OpenAPI extension for marshmallow_enum.EnumField instances + """ + import marshmallow_enum + + if isinstance(field, marshmallow_enum.EnumField): + return {"type": "string", "enum": [m.name for m in field.enum]} + return {} + + +from project import marshmallow_plugin + +marshmallow_plugin.converter.add_attribute_function(enum_to_properties) + import project.api.event.resources +import project.api.event_date.resources +import project.api.image.resources +import project.api.location.resources import project.api.organization.resources import project.api.organizer.resources +import project.api.place.resources diff --git a/project/api/event/schemas.py b/project/api/event/schemas.py index 3b17bed..913d290 100644 --- a/project/api/event/schemas.py +++ b/project/api/event/schemas.py @@ -1,6 +1,12 @@ from project import marshmallow from marshmallow import fields -from project.models import Event +from marshmallow_enum import EnumField +from project.models import Event, EventStatus +from project.api.schemas import PaginationRequestSchema, PaginationResponseSchema +from project.api.organization.schemas import OrganizationRefSchema +from project.api.organizer.schemas import OrganizerRefSchema +from project.api.image.schemas import ImageRefSchema +from project.api.place.schemas import PlaceRefSchema class EventSchema(marshmallow.SQLAlchemySchema): @@ -8,7 +14,11 @@ class EventSchema(marshmallow.SQLAlchemySchema): model = Event id = marshmallow.auto_field() + href = marshmallow.URLFor("eventresource", values=dict(id="")) name = marshmallow.auto_field() + start = marshmallow.auto_field() + end = marshmallow.auto_field() + recurrence_rule = marshmallow.auto_field() description = marshmallow.auto_field() external_link = marshmallow.auto_field() ticket_link = marshmallow.auto_field() @@ -17,14 +27,28 @@ class EventSchema(marshmallow.SQLAlchemySchema): accessible_for_free = marshmallow.auto_field() age_from = marshmallow.auto_field() age_to = marshmallow.auto_field() + status = EnumField(EventStatus) - organization = marshmallow.HyperlinkRelated( - "organizationresource", attribute="admin_unit" - ) - organizer = marshmallow.URLFor( - "organizerresource", - values=dict(organization_id="", organizer_id=""), - ) + organization = fields.Nested(OrganizationRefSchema, attribute="admin_unit") + organizer = fields.Nested(OrganizerRefSchema) + photo = fields.Nested(ImageRefSchema) + place = fields.Nested(PlaceRefSchema, attribute="event_place") + + +class EventRefSchema(marshmallow.SQLAlchemySchema): + class Meta: + model = Event + + id = marshmallow.auto_field() + href = marshmallow.URLFor("eventresource", values=dict(id="")) + name = marshmallow.auto_field() + description = marshmallow.auto_field() + status = EnumField(EventStatus) + + organization = fields.Nested(OrganizationRefSchema, attribute="admin_unit") + organizer = fields.Nested(OrganizerRefSchema) + photo = fields.Nested(ImageRefSchema) + place = fields.Nested(PlaceRefSchema, attribute="event_place") class EventListItemSchema(marshmallow.SQLAlchemySchema): @@ -39,41 +63,11 @@ class EventListItemSchema(marshmallow.SQLAlchemySchema): recurrence_rule = marshmallow.auto_field() -class EventListRequestSchema(marshmallow.Schema): - page = fields.Integer( - required=False, - default=1, - metadata={"description": "The page number (1 indexed)."}, - ) - per_page = fields.Integer( - required=False, default=20, metadata={"description": "Items per page"} - ) +class EventListRequestSchema(PaginationRequestSchema): + pass -class EventListResponseSchema(marshmallow.Schema): - has_next = fields.Boolean( - required=True, metadata={"description": "True if a next page exists."} - ) - has_prev = fields.Boolean( - required=True, metadata={"description": "True if a previous page exists."} - ) - next_num = fields.Integer( - required=False, metadata={"description": "Number of the next page."} - ) - prev_num = fields.Integer( - required=False, metadata={"description": "Number of the previous page."} - ) - page = fields.Integer( - required=True, metadata={"description": "The current page number (1 indexed)."} - ) - pages = fields.Integer( - required=True, metadata={"description": "The total number of pages."} - ) - per_page = fields.Integer(required=True, metadata={"description": "Items per page"}) - total = fields.Integer( - required=True, - metadata={"description": "The total number of items matching the query"}, - ) +class EventListResponseSchema(PaginationResponseSchema): items = fields.List( fields.Nested(EventListItemSchema), metadata={"description": "Events"} ) diff --git a/project/api/event_date/__init__.py b/project/api/event_date/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/api/event_date/resources.py b/project/api/event_date/resources.py new file mode 100644 index 0000000..0286f6a --- /dev/null +++ b/project/api/event_date/resources.py @@ -0,0 +1,32 @@ +from project import rest_api, api_docs +from flask_apispec import marshal_with, doc, use_kwargs +from flask_apispec.views import MethodResource +from project.api.event_date.schemas import ( + EventDateSchema, + EventDateListRequestSchema, + EventDateListResponseSchema, +) +from project.models import EventDate + + +class EventDateListResource(MethodResource): + @doc(tags=["Event Dates"]) + @use_kwargs(EventDateListRequestSchema, location=("query")) + @marshal_with(EventDateListResponseSchema) + def get(self, **kwargs): + pagination = EventDate.query.paginate() + return pagination + + +class EventDateResource(MethodResource): + @doc(tags=["Event Dates"]) + @marshal_with(EventDateSchema) + def get(self, id): + return EventDate.query.get_or_404(id) + + +rest_api.add_resource(EventDateListResource, "/event_dates") +api_docs.register(EventDateListResource) + +rest_api.add_resource(EventDateResource, "/event_dates/") +api_docs.register(EventDateResource) diff --git a/project/api/event_date/schemas.py b/project/api/event_date/schemas.py new file mode 100644 index 0000000..9f983c0 --- /dev/null +++ b/project/api/event_date/schemas.py @@ -0,0 +1,36 @@ +from project import marshmallow +from marshmallow import fields +from project.models import EventDate +from project.api.event.schemas import EventSchema, EventRefSchema +from project.api.schemas import PaginationRequestSchema, PaginationResponseSchema + + +class EventDateSchema(marshmallow.SQLAlchemySchema): + class Meta: + model = EventDate + + id = marshmallow.auto_field() + start = marshmallow.auto_field() + end = marshmallow.auto_field() + event = fields.Nested(EventSchema) + + +class EventDateListItemSchema(marshmallow.SQLAlchemySchema): + class Meta: + model = EventDate + + id = marshmallow.auto_field() + href = marshmallow.URLFor("eventdateresource", values=dict(id="")) + start = marshmallow.auto_field() + end = marshmallow.auto_field() + event = fields.Nested(EventRefSchema) + + +class EventDateListRequestSchema(PaginationRequestSchema): + pass + + +class EventDateListResponseSchema(PaginationResponseSchema): + items = fields.List( + fields.Nested(EventDateListItemSchema), metadata={"description": "Dates"} + ) diff --git a/project/api/image/__init__.py b/project/api/image/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/api/image/resources.py b/project/api/image/resources.py new file mode 100644 index 0000000..864bd2d --- /dev/null +++ b/project/api/image/resources.py @@ -0,0 +1,16 @@ +from project import rest_api, api_docs +from flask_apispec import marshal_with, doc +from flask_apispec.views import MethodResource +from project.api.image.schemas import ImageSchema +from project.models import AdminUnit + + +class ImageResource(MethodResource): + @doc(tags=["Images"]) + @marshal_with(ImageSchema) + def get(self, id): + return AdminUnit.query.get_or_404(id) + + +rest_api.add_resource(ImageResource, "/images/") +api_docs.register(ImageResource) diff --git a/project/api/image/schemas.py b/project/api/image/schemas.py new file mode 100644 index 0000000..e9f1093 --- /dev/null +++ b/project/api/image/schemas.py @@ -0,0 +1,24 @@ +from project import marshmallow +from project.models import Image + + +class ImageSchema(marshmallow.SQLAlchemySchema): + class Meta: + model = Image + + id = marshmallow.auto_field() + copyright_text = marshmallow.auto_field() + image_url = marshmallow.URLFor("image", values=dict(id="")) + + +class ImageRefSchema(marshmallow.SQLAlchemySchema): + class Meta: + model = Image + + id = marshmallow.auto_field() + copyright_text = marshmallow.auto_field() + image_url = marshmallow.URLFor("image", values=dict(id="")) + href = marshmallow.URLFor( + "imageresource", + values=dict(id=""), + ) diff --git a/project/api/location/__init__.py b/project/api/location/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/api/location/resources.py b/project/api/location/resources.py new file mode 100644 index 0000000..2f9a3a0 --- /dev/null +++ b/project/api/location/resources.py @@ -0,0 +1,16 @@ +from project import rest_api, api_docs +from flask_apispec import marshal_with, doc +from flask_apispec.views import MethodResource +from project.api.location.schemas import LocationSchema +from project.models import Location + + +class LocationResource(MethodResource): + @doc(tags=["Locations"]) + @marshal_with(LocationSchema) + def get(self, id): + return Location.query.get_or_404(id) + + +rest_api.add_resource(LocationResource, "/locations/") +api_docs.register(LocationResource) diff --git a/project/api/location/schemas.py b/project/api/location/schemas.py new file mode 100644 index 0000000..5d26f52 --- /dev/null +++ b/project/api/location/schemas.py @@ -0,0 +1,30 @@ +from marshmallow import fields +from project import marshmallow +from project.models import Location + + +class LocationSchema(marshmallow.SQLAlchemySchema): + class Meta: + model = Location + + id = marshmallow.auto_field() + street = marshmallow.auto_field() + postalCode = marshmallow.auto_field() + city = marshmallow.auto_field() + state = marshmallow.auto_field() + country = marshmallow.auto_field() + longitude = fields.Str() + latitude = fields.Str() + + +class LocationRefSchema(marshmallow.SQLAlchemySchema): + class Meta: + model = Location + + id = marshmallow.auto_field() + longitude = fields.Str() + latitude = fields.Str() + href = marshmallow.URLFor( + "locationresource", + values=dict(id=""), + ) diff --git a/project/api/organization/schemas.py b/project/api/organization/schemas.py index cf72552..e822800 100644 --- a/project/api/organization/schemas.py +++ b/project/api/organization/schemas.py @@ -12,3 +12,12 @@ class OrganizationSchema(marshmallow.SQLAlchemySchema): email = marshmallow.auto_field() phone = marshmallow.auto_field() fax = marshmallow.auto_field() + + +class OrganizationRefSchema(marshmallow.SQLAlchemySchema): + class Meta: + model = AdminUnit + + id = marshmallow.auto_field() + name = marshmallow.auto_field() + href = marshmallow.URLFor("organizationresource", values=dict(id="")) diff --git a/project/api/organizer/resources.py b/project/api/organizer/resources.py index 76da9ea..64d07c3 100644 --- a/project/api/organizer/resources.py +++ b/project/api/organizer/resources.py @@ -8,12 +8,12 @@ from project.models import EventOrganizer class OrganizerResource(MethodResource): @doc(tags=["Organizers"]) @marshal_with(OrganizerSchema) - def get(self, organization_id, organizer_id): - return EventOrganizer.query.get_or_404(organizer_id) + def get(self, id): + return EventOrganizer.query.get_or_404(id) rest_api.add_resource( OrganizerResource, - "/organizations//organizers/", + "/organizers/", ) api_docs.register(OrganizerResource) diff --git a/project/api/organizer/schemas.py b/project/api/organizer/schemas.py index f913405..0743115 100644 --- a/project/api/organizer/schemas.py +++ b/project/api/organizer/schemas.py @@ -8,3 +8,15 @@ class OrganizerSchema(marshmallow.SQLAlchemySchema): id = marshmallow.auto_field() name = marshmallow.auto_field() + + +class OrganizerRefSchema(marshmallow.SQLAlchemySchema): + class Meta: + model = EventOrganizer + + id = marshmallow.auto_field() + name = marshmallow.auto_field() + href = marshmallow.URLFor( + "organizerresource", + values=dict(id=""), + ) diff --git a/project/api/place/__init__.py b/project/api/place/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/api/place/resources.py b/project/api/place/resources.py new file mode 100644 index 0000000..2d46f3e --- /dev/null +++ b/project/api/place/resources.py @@ -0,0 +1,19 @@ +from project import rest_api, api_docs +from flask_apispec import marshal_with, doc +from flask_apispec.views import MethodResource +from project.api.place.schemas import PlaceSchema +from project.models import EventPlace + + +class PlaceResource(MethodResource): + @doc(tags=["Places"]) + @marshal_with(PlaceSchema) + def get(self, id): + return EventPlace.query.get_or_404(id) + + +rest_api.add_resource( + PlaceResource, + "/places/", +) +api_docs.register(PlaceResource) diff --git a/project/api/place/schemas.py b/project/api/place/schemas.py new file mode 100644 index 0000000..08189ad --- /dev/null +++ b/project/api/place/schemas.py @@ -0,0 +1,30 @@ +from marshmallow import fields +from project import marshmallow +from project.models import EventPlace +from project.api.image.schemas import ImageRefSchema +from project.api.location.schemas import LocationRefSchema + + +class PlaceSchema(marshmallow.SQLAlchemySchema): + class Meta: + model = EventPlace + + id = marshmallow.auto_field() + name = marshmallow.auto_field() + url = marshmallow.auto_field() + description = marshmallow.auto_field() + photo = fields.Nested(ImageRefSchema) + location = fields.Nested(LocationRefSchema) + + +class PlaceRefSchema(marshmallow.SQLAlchemySchema): + class Meta: + model = EventPlace + + id = marshmallow.auto_field() + name = marshmallow.auto_field() + href = marshmallow.URLFor( + "placeresource", + values=dict(id=""), + ) + location = fields.Nested(LocationRefSchema) diff --git a/project/api/schemas.py b/project/api/schemas.py new file mode 100644 index 0000000..fbb4bef --- /dev/null +++ b/project/api/schemas.py @@ -0,0 +1,39 @@ +from project import marshmallow +from marshmallow import fields + + +class PaginationRequestSchema(marshmallow.Schema): + page = fields.Integer( + required=False, + default=1, + metadata={"description": "The page number (1 indexed)."}, + ) + per_page = fields.Integer( + required=False, default=20, metadata={"description": "Items per page"} + ) + + +class PaginationResponseSchema(marshmallow.Schema): + has_next = fields.Boolean( + required=True, metadata={"description": "True if a next page exists."} + ) + has_prev = fields.Boolean( + required=True, metadata={"description": "True if a previous page exists."} + ) + next_num = fields.Integer( + required=False, metadata={"description": "Number of the next page."} + ) + prev_num = fields.Integer( + required=False, metadata={"description": "Number of the previous page."} + ) + page = fields.Integer( + required=True, metadata={"description": "The current page number (1 indexed)."} + ) + pages = fields.Integer( + required=True, metadata={"description": "The total number of pages."} + ) + per_page = fields.Integer(required=True, metadata={"description": "Items per page"}) + total = fields.Integer( + required=True, + metadata={"description": "The total number of items matching the query"}, + ) diff --git a/requirements.txt b/requirements.txt index ca77ed4..9dd5ae4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -52,6 +52,7 @@ jsonschema==3.2.0 Mako==1.1.3 MarkupSafe==1.1.1 marshmallow==3.10.0 +marshmallow-enum==1.5.1 marshmallow-sqlalchemy==0.24.1 mccabe==0.6.1 mistune==0.8.4 diff --git a/tests/api/test_event.py b/tests/api/test_event.py index 61e197b..e64159e 100644 --- a/tests/api/test_event.py +++ b/tests/api/test_event.py @@ -1,9 +1,20 @@ -def test_read(client, seeder, utils): +def test_read(client, app, db, seeder, utils): user_id, admin_unit_id = seeder.setup_base() event_id = seeder.create_event(admin_unit_id) + with app.app_context(): + from project.models import Event, EventStatus + from project.services.event import update_event + + event = Event.query.get(event_id) + event.status = EventStatus.scheduled + + update_event(event) + db.session.commit() + url = utils.get_url("eventresource", id=event_id) - utils.get_ok(url) + response = utils.get_ok(url) + assert response.json["status"] == "scheduled" def test_list(client, seeder, utils): diff --git a/tests/api/test_event_date.py b/tests/api/test_event_date.py new file mode 100644 index 0000000..d1b2793 --- /dev/null +++ b/tests/api/test_event_date.py @@ -0,0 +1,14 @@ +def test_read(client, seeder, utils): + user_id, admin_unit_id = seeder.setup_base() + seeder.create_event(admin_unit_id) + + url = utils.get_url("eventdateresource", id=1) + utils.get_ok(url) + + +def test_list(client, seeder, utils): + user_id, admin_unit_id = seeder.setup_base() + seeder.create_event(admin_unit_id) + + url = utils.get_url("eventdatelistresource") + utils.get_ok(url) diff --git a/tests/api/test_image.py b/tests/api/test_image.py new file mode 100644 index 0000000..a4b5154 --- /dev/null +++ b/tests/api/test_image.py @@ -0,0 +1,6 @@ +def test_read(client, seeder, utils): + user_id, admin_unit_id = seeder.setup_base() + image_id = seeder.upsert_default_image() + + url = utils.get_url("imageresource", id=image_id) + utils.get_ok(url) diff --git a/tests/api/test_location.py b/tests/api/test_location.py new file mode 100644 index 0000000..dfb1994 --- /dev/null +++ b/tests/api/test_location.py @@ -0,0 +1,20 @@ +def test_read(client, app, db, seeder, utils): + user_id, admin_unit_id = seeder.setup_base() + + with app.app_context(): + from project.models import Location + + location = Location() + location.street = "Markt 7" + location.postalCode = "38640" + location.city = "Goslar" + location.latitude = 51.9077888 + location.longitude = 10.4333312 + + db.session.add(location) + db.session.commit() + location_id = location.id + + url = utils.get_url("locationresource", id=location_id) + response = utils.get_ok(url) + assert response.json["latitude"] == "51.9077888000000000" diff --git a/tests/api/test_organizer.py b/tests/api/test_organizer.py index 2d84591..e1cbc7b 100644 --- a/tests/api/test_organizer.py +++ b/tests/api/test_organizer.py @@ -2,7 +2,5 @@ def test_read(client, seeder, utils): user_id, admin_unit_id = seeder.setup_base() organizer_id = seeder.upsert_default_event_organizer(admin_unit_id) - url = utils.get_url( - "organizerresource", organization_id=admin_unit_id, organizer_id=organizer_id - ) + url = utils.get_url("organizerresource", id=organizer_id) utils.get_ok(url) diff --git a/tests/api/test_place.py b/tests/api/test_place.py new file mode 100644 index 0000000..9669a89 --- /dev/null +++ b/tests/api/test_place.py @@ -0,0 +1,6 @@ +def test_read(client, app, db, seeder, utils): + user_id, admin_unit_id = seeder.setup_base() + place_id = seeder.upsert_default_event_place(admin_unit_id) + + url = utils.get_url("placeresource", id=place_id) + utils.get_ok(url)