diff --git a/ReadMe.md b/ReadMe.md index 4ca1387b..85873bf0 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,10 +1,20 @@ -# Django Graphene (GraphQL) API +# Schedule Booking GraphQL API + +Schedule Booking app is powered by a GraphQL API. GraphQL is a query language that allows clients to talk to an API server. Unlike REST, it gives the client control over how much or how little data they want to request about each object and allows relations within the object graph to be traversed easily. + +To learn more about GraphQL language and its concepts, see the official [GraphQL website](https://graphql.org/). + +The API endpoint is available at `/graphql/` and requires queries to be submitted using HTTP `POST` method and the `application/json` content type. + +The API provides simple CURD operation. The application has simple data flow where +authenticated users can create their availabilities slots and other users can +book these slots. Other uses can also see all the bookings of a user. +CURD operations are provided on authenticated availability endpoint using `JWT` authentication +mechanism. API provides both types of operations: + +* Public (search & book slots in availability.) +* Private (create/update/delete availabilities) -This is simple application which provided CURD operation. The application has simple -data flow where authenticated users can create their availabilities slots and -other users can book these slots. Other uses can also see all the bookings of a user. -CURD ops are provided on authenticated availability endpoint using `JWT` authentication -mechanism. **Project Requirements:** @@ -22,7 +32,7 @@ testing GraphQL queries. Follow the step by step guide below to run & test the G Screen Shot 2021-12-24 at 3 25 55 AM -### Getting Started +### Development Setup This project is created and tested with `Python 3.8.10` #### Create & activate virtual environment. diff --git a/scheduler/api/schema.py b/scheduler/api/schema.py new file mode 100644 index 00000000..0f148fdf --- /dev/null +++ b/scheduler/api/schema.py @@ -0,0 +1,17 @@ +import graphene +from graphql_auth.schema import UserQuery + +from scheduler.meeting_scheduler.schema import ( + AvailabilityQuery, BookingQuery, AvailabilityMutation, BookingMutation, UserMutation +) + + +class Query(BookingQuery, AvailabilityQuery, UserQuery): + pass + + +class Mutation(AvailabilityMutation, BookingMutation, UserMutation): + pass + + +schema = graphene.Schema(query=Query, mutation=Mutation) diff --git a/scheduler/api/tests.py b/scheduler/api/tests.py index 191635fd..0c76a808 100644 --- a/scheduler/api/tests.py +++ b/scheduler/api/tests.py @@ -3,7 +3,7 @@ """ from datetime import time -from scheduler.meeting_scheduler.schema import schema +from .schema import schema from scheduler.meeting_scheduler.tests import BaseTests @@ -11,6 +11,7 @@ class BookingAPITests(BaseTests): """ Booking api tests. """ + def setUp(self) -> None: self.user = self.create_user(username="api-user") self.availability = self.create_availability(self.user) @@ -20,9 +21,10 @@ def setUp(self) -> None: start_time=time(hour=11, minute=0, second=0), total_time=15 ) + self.booking_by_user_query = ''' - query getUserBookings { - bookingsByUser(username: "api-user"){ + query getUserBookings($username: String!) { + bookingsByUser(username: $username){ id user { id username email } @@ -30,17 +32,44 @@ def setUp(self) -> None: } ''' + @classmethod + def execute_and_assert_success(cls, query, **kwargs): + """ + Run the query and assert there were no errors. + """ + result = schema.execute(query, **kwargs) + + assert result.errors is None, result.errors + return result.data + + @classmethod + def execute_and_assert_error(cls, query, error, **kwargs): + """ + Run the query and assert there the expected error is raised. + """ + result = schema.execute(query, **kwargs) + assert result.errors is not None, "No errors while executing query!" + assert any( + [error in err.message for err in result.errors] + ) is True, f'No error {error} instead {result.errors}' + return result.errors + def test_user_has_one_booking(self): """Test that get user booking api returns data.""" - result = schema.execute(self.booking_by_user_query) - data = result.data + data = self.execute_and_assert_success( + self.booking_by_user_query, + variables={"username": "api-user"} + ) + assert data is not None assert len(data['bookingsByUser']) == 1 def test_user_booking_fields(self): """Test that get user booking api returns expected data.""" - result = schema.execute(self.booking_by_user_query) - booking = result.data['bookingsByUser'][0] + booking = self.execute_and_assert_success( + self.booking_by_user_query, + variables={"username": "api-user"} + )['bookingsByUser'][0] assert booking['id'] == f'{self.user_booking.id}' assert booking['user'] == {'id': f'{self.user.id}', 'username': 'api-user', 'email': self.user.email} @@ -50,9 +79,9 @@ def test_user_booking_additional_fields(self): Tests that you can provide additional api key fields and the api returns those additional fields. """ - self.booking_by_user_query = ''' - query getUserBookings { - bookingsByUser(username: "api-user"){ + query = ''' + query getUserBookings($username: String!) { + bookingsByUser(username: $username){ id fullName email date startTime endTime totalTime updatedAt user { id username email @@ -60,7 +89,32 @@ def test_user_booking_additional_fields(self): } } ''' - result = schema.execute(self.booking_by_user_query) - booking = result.data['bookingsByUser'][0] + booking = self.execute_and_assert_success( + query, + variables={"username": "api-user"} + )['bookingsByUser'][0] + for field in "id fullName email date startTime endTime totalTime updatedAt".split(): assert field in booking + + def test_variable_error(self): + """""" + query = ''' + query getUserBookings($username: String) { + bookingsByUser(username: $username){ + id fullName email date startTime endTime totalTime updatedAt + user { + id username email + } + } + } + ''' + expected_error = 'Variable "username" of type "String" used in position expecting type "String!".' + self.execute_and_assert_error(query=query, variables={"username": "api-user"}, error=expected_error) + + def test_missing_variable(self): + """ + Test that missing variable error is raised when variable is not provided. + """ + expected_error = 'Variable "$username" of required type "String!" was not provided.' + self.execute_and_assert_error(self.booking_by_user_query, error=expected_error) diff --git a/scheduler/api/urls.py b/scheduler/api/urls.py index c4de0aac..cf89484c 100644 --- a/scheduler/api/urls.py +++ b/scheduler/api/urls.py @@ -5,9 +5,8 @@ from django.views.decorators.csrf import csrf_exempt from graphene_django.views import GraphQLView -from scheduler.meeting_scheduler.schema import schema +from .schema import schema urlpatterns = [ path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema))), - ] diff --git a/scheduler/meeting_scheduler/admin.py b/scheduler/meeting_scheduler/admin.py index 603991d0..435a02c8 100644 --- a/scheduler/meeting_scheduler/admin.py +++ b/scheduler/meeting_scheduler/admin.py @@ -5,11 +5,12 @@ from django.contrib import admin from django.contrib.sessions.models import Session -from scheduler.meeting_scheduler.models import Booking, Availability, UserModel as User +from .models import Booking, Availability, UserModel as User class SessionAdmin(admin.ModelAdmin): """Django session model admin """ + def _session_data(self, obj): """Return decoded session data.""" return obj.get_decoded() diff --git a/scheduler/meeting_scheduler/mutations.py b/scheduler/meeting_scheduler/mutations.py index e432a02a..ba799158 100644 --- a/scheduler/meeting_scheduler/mutations.py +++ b/scheduler/meeting_scheduler/mutations.py @@ -5,17 +5,17 @@ import graphene from graphql import GraphQLError -from scheduler.meeting_scheduler.decorators import user_required -from scheduler.meeting_scheduler.enums import Description -from scheduler.meeting_scheduler.models import Booking, UserModel as User, Availability -from scheduler.meeting_scheduler.nodes import BookingNode, AvailabilityNode +from .decorators import user_required +from .enums import Description +from .models import Booking, UserModel as User, Availability +from .types import BookingType, AvailabilityType class CreateBooking(graphene.Mutation): """ OTD mutation class for creating bookings with users. """ - booking = graphene.Field(BookingNode) + booking = graphene.Field(BookingType) success = graphene.Boolean() class Arguments: @@ -50,7 +50,7 @@ class CreateAvailability(graphene.Mutation): """ OTD mutation class for creating user availabilities. """ - availability = graphene.Field(AvailabilityNode) + availability = graphene.Field(AvailabilityType) success = graphene.Boolean() error = graphene.String() @@ -78,7 +78,7 @@ class UpdateAvailability(graphene.Mutation): """ OTD mutation class for updating user availabilities. """ - availability = graphene.Field(AvailabilityNode) + availability = graphene.Field(AvailabilityType) success = graphene.Boolean() error = graphene.String() diff --git a/scheduler/meeting_scheduler/schema.py b/scheduler/meeting_scheduler/schema.py index f988fa96..f86a8616 100644 --- a/scheduler/meeting_scheduler/schema.py +++ b/scheduler/meeting_scheduler/schema.py @@ -3,29 +3,39 @@ from graphql_auth.schema import UserQuery from graphql_jwt.decorators import user_passes_test -from scheduler.meeting_scheduler.models import Booking, Availability -from scheduler.meeting_scheduler.mutations import ( +from .models import Booking, Availability +from .mutations import ( CreateBooking, CreateAvailability, DeleteAvailability, UpdateAvailability, ) -from scheduler.meeting_scheduler.nodes import AvailabilityNode, BookingNode +from .types import AvailabilityType, BookingType -class Query(UserQuery, graphene.ObjectType): +class BookingQuery(graphene.ObjectType): """ - Describes entry point for fields to *read* data in the booking Schema. + Describes entry point for fields to *read* data in the booking schema. """ - availabilities = graphene.List(AvailabilityNode) - availability = graphene.Field(AvailabilityNode, id=graphene.Int( - required=True, description="ID of a availability to view" - )) - bookings_by_user = graphene.List( - BookingNode, - username=graphene.Argument( - graphene.String, description="Pass username of the user.", required=True - ), + BookingType, + username=graphene.String(required=True), + # Alternative + # username=graphene.Argument(graphene.String, description="Pass username of the user.", required=True), ) + @classmethod + def resolve_bookings_by_user(cls, root, info, username): + """Resolve bookings by user""" + return Booking.objects.filter(user__username=username).prefetch_related('user') + + +class AvailabilityQuery(UserQuery, graphene.ObjectType): + """ + Describes entry point for fields to *read* data in the availability schema. + """ + availabilities = graphene.List(AvailabilityType) + availability = graphene.Field(AvailabilityType, id=graphene.Int( + required=True, description="ID of a availability to view" + )) + @classmethod @user_passes_test(lambda user: user and not user.is_anonymous) def resolve_availabilities(cls, root, info): @@ -38,27 +48,26 @@ def resolve_availability(cls, root, info, id): """Resolve the user availability field""" return Availability.objects.get(id=id, user=info.context.user) - @classmethod - def resolve_bookings_by_user(cls, root, info, username): - """Resolve bookings by user""" - return Booking.objects.filter(user__username=username).prefetch_related('user') +class BookingMutation(graphene.ObjectType): + """ + Describes entry point for fields to *create* data in bookings API. + """ + create_booking = CreateBooking.Field() -class Mutation(graphene.ObjectType): + +class AvailabilityMutation(graphene.ObjectType): """ - Describes entry point for fields to *create, update or delete* data in bookings API. + Describes entry point for fields to *create, update or delete* data in availability API. """ - # Availability mutations create_availability = CreateAvailability.Field() update_availability = UpdateAvailability.Field() delete_availability = DeleteAvailability.Field() - # Booking mutations - create_booking = CreateBooking.Field() - # User mutations +class UserMutation(graphene.ObjectType): + """ + Describes entry point for fields to *login, verify token* data in user API. + """ login = mutations.ObtainJSONWebToken.Field(description="Login and obtain token for the user") verify_token = mutations.VerifyToken.Field(description="Verify if the token is valid.") - - -schema = graphene.Schema(query=Query, mutation=Mutation) diff --git a/scheduler/meeting_scheduler/tests.py b/scheduler/meeting_scheduler/tests.py index b875042c..2aedda6f 100644 --- a/scheduler/meeting_scheduler/tests.py +++ b/scheduler/meeting_scheduler/tests.py @@ -4,7 +4,7 @@ from django.test import TestCase -from scheduler.meeting_scheduler.models import Booking, UserModel, Availability +from .models import Booking, UserModel, Availability class BaseTests(TestCase): diff --git a/scheduler/meeting_scheduler/nodes.py b/scheduler/meeting_scheduler/types.py similarity index 82% rename from scheduler/meeting_scheduler/nodes.py rename to scheduler/meeting_scheduler/types.py index 1eba1afe..b8d586e8 100644 --- a/scheduler/meeting_scheduler/nodes.py +++ b/scheduler/meeting_scheduler/types.py @@ -1,11 +1,10 @@ """ Custom scheduler app nodes """ - import graphene from graphene_django import DjangoObjectType -from scheduler.meeting_scheduler.models import Booking, Availability, UserModel +from .models import Booking, Availability, UserModel class UserType(DjangoObjectType): @@ -16,7 +15,7 @@ class Meta: fields = ("id", "username", "email") -class AvailabilityNode(DjangoObjectType): +class AvailabilityType(DjangoObjectType): """Availability Object Type Definition""" id = graphene.ID() interval_mints = graphene.String() @@ -31,7 +30,7 @@ def resolve_interval_mints(cls, availability, info): return availability.get_interval_mints_display() -class BookingNode(DjangoObjectType): +class BookingType(DjangoObjectType): """Booking Object Type Definition""" id = graphene.ID() user = graphene.Field(UserType) diff --git a/scheduler/settings.py b/scheduler/settings.py index cd2eb278..41bf8cba 100644 --- a/scheduler/settings.py +++ b/scheduler/settings.py @@ -39,8 +39,11 @@ 'graphql_jwt.refresh_token.apps.RefreshTokenConfig', 'graphql_auth', 'django_filters', + 'django.contrib.sites', ] +SITE_ID = 1 + GRAPHENE = { "MIDDLEWARE": [ "graphql_jwt.middleware.JSONWebTokenMiddleware", @@ -65,6 +68,7 @@ } MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sites.middleware.CurrentSiteMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',