diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 5ecafb75b5..d7b6cd1836 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -255,7 +255,9 @@ IN_GAME_ERRORS = True DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - "NAME": os.getenv("TEST_DB_PATH", os.path.join(GAME_DIR, "server", "evennia.db3")), + "NAME": os.getenv( + "TEST_DB_PATH", os.path.join(GAME_DIR, "server", "evennia.db3") + ), "USER": "", "PASSWORD": "", "HOST": "", @@ -472,7 +474,12 @@ SERVER_SESSION_CLASS = "evennia.server.serversession.ServerSession" # immediately entered path fail to find a typeclass. It allows for # shorter input strings. They must either base off the game directory # or start from the evennia library. -TYPECLASS_PATHS = ["typeclasses", "evennia", "evennia.contrib", "evennia.contrib.tutorial_examples"] +TYPECLASS_PATHS = [ + "typeclasses", + "evennia", + "evennia.contrib", + "evennia.contrib.tutorial_examples", +] # Typeclass for account objects (linked to a character) (fallback) BASE_ACCOUNT_TYPECLASS = "typeclasses.accounts.Account" @@ -550,7 +557,11 @@ VALIDATOR_FUNC_MODULES = ["evennia.utils.validatorfuncs"] # Python path to a directory to be searched for batch scripts # for the batch processors (.ev and/or .py files). -BASE_BATCHPROCESS_PATHS = ["world", "evennia.contrib", "evennia.contrib.tutorial_examples"] +BASE_BATCHPROCESS_PATHS = [ + "world", + "evennia.contrib", + "evennia.contrib.tutorial_examples", +] ###################################################################### # Game Time setup @@ -861,7 +872,9 @@ TEMPLATES = [ os.path.join(GAME_DIR, "web", "template_overrides"), os.path.join(EVENNIA_DIR, "web", "website", "templates", WEBSITE_TEMPLATE), os.path.join(EVENNIA_DIR, "web", "website", "templates"), - os.path.join(EVENNIA_DIR, "web", "webclient", "templates", WEBCLIENT_TEMPLATE), + os.path.join( + EVENNIA_DIR, "web", "webclient", "templates", WEBCLIENT_TEMPLATE + ), os.path.join(EVENNIA_DIR, "web", "webclient", "templates"), ], "APP_DIRS": True, @@ -912,6 +925,8 @@ INSTALLED_APPS = [ "django.contrib.sites", "django.contrib.staticfiles", "django.contrib.messages", + "rest_framework", + "django_filters", "sekizai", "evennia.utils.idmapper", "evennia.server", @@ -931,7 +946,9 @@ AUTH_USER_MODEL = "accounts.AccountDB" # Password validation plugins # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ - {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" + }, { "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", "OPTIONS": {"min_length": 8}, @@ -944,8 +961,14 @@ AUTH_PASSWORD_VALIDATORS = [ # Username validation plugins AUTH_USERNAME_VALIDATORS = [ {"NAME": "django.contrib.auth.validators.ASCIIUsernameValidator"}, - {"NAME": "django.core.validators.MinLengthValidator", "OPTIONS": {"limit_value": 3}}, - {"NAME": "django.core.validators.MaxLengthValidator", "OPTIONS": {"limit_value": 30}}, + { + "NAME": "django.core.validators.MinLengthValidator", + "OPTIONS": {"limit_value": 3}, + }, + { + "NAME": "django.core.validators.MaxLengthValidator", + "OPTIONS": {"limit_value": 30}, + }, {"NAME": "evennia.server.validators.EvenniaUsernameAvailabilityValidator"}, ] @@ -956,6 +979,38 @@ TEST_RUNNER = "evennia.server.tests.testrunner.EvenniaTestSuiteRunner" # messages.error() to Bootstrap 'danger' classes. MESSAGE_TAGS = {messages.ERROR: "danger"} +# Django REST Framework settings +REST_FRAMEWORK = { + # django_filters allows you to specify search fields for models in an API View + 'DEFAULT_FILTER_BACKENDS': ( + 'django_filters.rest_framework.DjangoFilterBackend', + ), + # whether to paginate results and how many per page + "DEFAULT_PAGINATION_CLASS": 'rest_framework.pagination.LimitOffsetPagination', + 'PAGE_SIZE': 25, + # require logged in users to call API so that access checks can work on them + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], + # These are the different ways people can authenticate for API requests - via + # session or with user/password. Other ways are possible, such as via tokens + # or oauth, but require additional dependencies. + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.BasicAuthentication', + 'rest_framework.authentication.SessionAuthentication', + ], + # default permission checks used by the EvenniaPermission class + "DEFAULT_CREATE_PERMISSION": "builder", + "DEFAULT_LIST_PERMISSION": "builder", + "DEFAULT_VIEW_LOCKS": ["examine"], + "DEFAULT_DESTROY_LOCKS": ["delete"], + "DEFAULT_UPDATE_LOCKS": ["control", "edit"], + # No throttle class set by default. Setting one also requires a cache backend to be specified. +} + +# To enable the REST api, turn this to True +REST_API_ENABLED = False + ###################################################################### # Django extensions ###################################################################### diff --git a/evennia/typeclasses/managers.py b/evennia/typeclasses/managers.py index e9ad42ed11..c886a67400 100644 --- a/evennia/typeclasses/managers.py +++ b/evennia/typeclasses/managers.py @@ -573,7 +573,7 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): for parent in (parent for parent in parents if hasattr(parent, "path")): query = query | Q(db_typeclass_path__exact=parent.path) # actually query the database - return self.filter(query) + return super().filter(query) class TypeclassManager(TypedObjectManager): diff --git a/evennia/utils/picklefield.py b/evennia/utils/picklefield.py index 084fc6d638..a86f0099ca 100644 --- a/evennia/utils/picklefield.py +++ b/evennia/utils/picklefield.py @@ -277,7 +277,7 @@ class PickledObjectField(models.Field): return value def value_to_string(self, obj): - value = self._get_val_from_obj(obj) + value = self.value_from_object(obj) return self.get_db_prep_value(value) def get_internal_type(self): diff --git a/evennia/web/api/README.md b/evennia/web/api/README.md new file mode 100644 index 0000000000..d06838fbf4 --- /dev/null +++ b/evennia/web/api/README.md @@ -0,0 +1,144 @@ +# Evennia API + +## Synopsis +An API, or [Application Programming Interface][wiki-api], is a way of establishing rules +through which external services can use your program. In web development, it's +often that case that the 'frontend' of a web app is written in HTML and Javascript +and communicates with the 'backend' server through an API so that it can retrieve +information to populate web pages or process actions when users click buttons on +a web page. + +The API contained within the web/api/ package is an implementation of the +[Django Rest Framework][drf]. It provides tools to allow you to quickly process +requests for resources and generate responses. URLs, called endpoints, are +mapped to python classes called views, which handle requests for resources. +Requests might contain data that is formatted as [JSON strings][json], which DRF +can convert into python objects for you, a process called deserialization. +When returning a response, it can also convert python objects into JSON +strings to send back to a client, which is called serialization. Because it's +such a common task to want to handle [CRUD][crud] operations for the django models that you use to represent database +objects (such as your Character typeclass, Room typeclass, etc), DRF makes +this process very easy by letting you define [Serializers][serializers] +that largely automate the process of serializing your in-game objects into +JSON representations for sending them to a client, or for turning a JSON string +into a model for updating or creating it. + +## Motivations For Using An API + +Having an API can allow you to have richer interactions with client applications. For +example, suppose you want to allow players to send and receive in-game messages from +outside the game. You might define an endpoint that will retrieve all of a character's +messages and returns it as a JSON response. Then in a webpage, you have a button that +the user can click to make an [AJAX][ajax] request to that endpoint, retrieves the data, and +displays it on the page. You also provide a form to let them send messages, where the +submit button uses AJAX to make a POST request to that endpoint, sending along the +JSON data from the form, and then returns the response of the results. This works, +but then a tech-savvy player might ask if they can have their own application that +will retrieve messages periodically for their own computer. By having a [REST][rest] API that +they can use, they can create client applications of their own to retrieve or change +data. + +Other examples of what you might use a RESTful API for would be players managing +tasks out-of-game like crafting, guild management, retrieving stats on their +characters, building rooms/grids, editing character details, etc. Any task that +doesn't require real-time 2-way interaction is a good candidate for an API endpoint. + +## Sample requests + +The API contains a number of views already defined. If the API is enabled, by +setting `REST_API_ENABLED = True` in your `settings.py`, endpoints will be +accessible by users who make authenticated requests as users with builder +permissions. Individual objects will check lockstrings to determine if the +user has permission to perform retrieve/update/delete actions upon them. +To start with, you can view a synopsis of endpoints by making a GET request +to the `yourgame/api/` endpoint by using the excellent [requests library][requests]: +```pythonstub +>>> import requests +>>> r = requests.get("https://www.mygame.com/api", auth=("user", "pw")) +>>> r.json() +{'accounts': 'http://www.mygame.com/api/accounts/', + 'objects': 'http://www.mygame.com/api/objects/', +'characters': 'http://www.mygame.comg/api/characters/', +'exits': 'http://www.mygame.com/api/exits/', +'rooms': 'http://www.mygame.com/api/rooms/', +'scripts': 'http://www.mygame.com/api/scripts/'} +``` + +To view an object, you might make a request like this: +```pythonstub +>>> import requests +>>> response = requests.get("https://www.mygame.com/api/objects/57", + auth=("Myusername", "password123")) +>>> response.json() +{"db_key": "A rusty longsword", "id": 57, "db_location": 213, ...} +``` +The above example makes a GET request to the /objects/ endpoint to retrieve the +object with an ID of 57, retrieving basic data for it. + +For listing a number of objects, you might do this: +```pythonstub +>>> response = requests.get("https://www.mygame.com/api/objects", + auth=("Myusername", "password123")) +>>> response.json() +{ +"count": 125, +"next": "https://www.mygame.com/api/objects/?limit=25&offset=25", +"previous": null, +"results" : [{"db_key": "A rusty longsword", "id": 57, "db_location": 213, ...}]} +``` +In the above example, it now displays the objects inside the "results" array, while it has a "count" value +for the number of total objects, and "next" and "previous" links for the next and previous page, if any. +This is called [pagination][pagination], and the link displays "limit" and "offset" as query parameters that +can be added to the url to control the output. Other query parameters can be defined as [filters][filters] which +allow you to further narrow the results. For example, to only get accounts with developer permissions: +```pythonstub +>>> response = requests.get("https://www.mygame.com/api/accounts/?permission=developer", + auth=("user", "pw")) +>>> response.json() +{ +"count": 1, +"results": [{"username": "bob",...}] +} +``` + +Now suppose that you want +to use the API to create an object: +```pythonstub +>>> data = {"db_key": "A shiny sword"} +>>> response = requests.post("https://www.mygame.com/api/objects", + data=data, auth=("Anotherusername", "sekritpassword")) +>>> response.json() +{"db_key": "A shiny sword", "id": 214, "db_location": None, ...} +``` +In the above example, you make a POST request to the /objects/ endpoint with +the name of the object you wish to create passed along as data. Now suppose you +decided you didn't like the name, and wanted to change it for the newly created +object: +```pythonstub +>>> data = {"db_key": "An even SHINIER sword", "db_location": 50} +>>> response = requests.put("https://www.mygame.com/api/objects/214", + data=data, auth=("Alsoauser", "Badpassword")) +>>> response.json() +{"db_key": "An even SHINIER sword", "id": 214, "db_location": 50, ...} +``` +By making a PUT request to the endpoint that includes the object ID, it becomes +a request to update the object with the specified data you pass along. + +In most cases, you won't be making API requests to the backend with python, +but with Javascript from your frontend application. +There are many Javascript libraries which are meant to make this process +easier for requests from the frontend, such as [AXIOS][axios], or using +the native [Fetch][fetch]. + +[wiki-api]: https://en.wikipedia.org/wiki/Application_programming_interface +[drf]: https://www.django-rest-framework.org/ +[pagination]: https://www.django-rest-framework.org/api-guide/pagination/ +[filters]: https://www.django-rest-framework.org/api-guide/filtering/#filtering +[json]: https://en.wikipedia.org/wiki/JSON +[crud]: https://en.wikipedia.org/wiki/Create,_read,_update_and_delete +[serializers]: https://www.django-rest-framework.org/api-guide/serializers/ +[ajax]: https://en.wikipedia.org/wiki/Ajax_(programming) +[rest]: https://en.wikipedia.org/wiki/Representational_state_transfer +[requests]: https://requests.readthedocs.io/en/master/ +[axios]: https://github.com/axios/axios +[fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API \ No newline at end of file diff --git a/evennia/web/api/__init__.py b/evennia/web/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/web/api/filters.py b/evennia/web/api/filters.py new file mode 100644 index 0000000000..6284af08ae --- /dev/null +++ b/evennia/web/api/filters.py @@ -0,0 +1,104 @@ +""" +FilterSets allow clients to specify querystrings that will determine the data +that is retrieved in GET requests. By default, Django Rest Framework uses the +'django-filter' package as its backend. Django-filter also has a section in its +documentation specifically regarding DRF integration. + +https://django-filter.readthedocs.io/en/latest/guide/rest_framework.html +""" +from typing import Union +from django.db.models import Q +from django_filters.rest_framework.filterset import FilterSet +from django_filters.filters import CharFilter, EMPTY_VALUES + +from evennia.objects.models import ObjectDB +from evennia.accounts.models import AccountDB +from evennia.scripts.models import ScriptDB + + +def get_tag_query(tag_type: Union[str, None], key: str) -> Q: + """ + Returns a Q object for searching by tag names for typeclasses + Args: + tag_type(str or None): The type of tag (None, 'alias', etc) + key (str): The name of the tag + + Returns: + A Q object that for searching by this tag type and name + """ + return Q(db_tags__db_tagtype=tag_type) & Q(db_tags__db_key__iexact=key) + + +class TagTypeFilter(CharFilter): + """ + This class lets you create different filters for tags of a specified db_tagtype. + """ + tag_type = None + + def filter(self, qs, value): + # if no value is specified, we don't use the filter + if value in EMPTY_VALUES: + return qs + # if they enter a value, we filter objects by having a tag of this type with the given name + return qs.filter(get_tag_query(self.tag_type, value)).distinct() + + +class AliasFilter(TagTypeFilter): + """A filter for objects by their aliases (tags with a tagtype of 'alias'""" + tag_type = "alias" + + +class PermissionFilter(TagTypeFilter): + """A filter for objects by their permissions (tags with a tagtype of 'permission'""" + tag_type = "permission" + + +SHARED_FIELDS = ["db_key", "db_typeclass_path", "db_tags__db_key", "db_tags__db_category"] + + +class BaseTypeclassFilterSet(FilterSet): + """A parent class with filters for aliases and permissions""" + alias = AliasFilter(lookup_expr="iexact") + permission = PermissionFilter(lookup_expr="iexact") + name = CharFilter(lookup_expr="iexact", method="filter_name", field_name="db_key") + + @staticmethod + def filter_name(queryset, name, value): + """ + Filters a queryset by aliases or the key of the typeclass + Args: + queryset: The queryset being filtered + name: The name of the field + value: The value passed in from GET params + + Returns: + The filtered queryset + """ + query = Q(**{f"{name}__iexact": value}) + query |= get_tag_query("alias", value) + return queryset.filter(query).distinct() + + +class ObjectDBFilterSet(BaseTypeclassFilterSet): + """This adds filters for ObjectDB instances - characters, rooms, exits, etc""" + class Meta: + model = ObjectDB + fields = SHARED_FIELDS + ["db_location__db_key", "db_home__db_key", "db_location__id", + "db_home__id"] + + +class AccountDBFilterSet(BaseTypeclassFilterSet): + """This adds filters for Account objects""" + name = CharFilter(lookup_expr="iexact", method="filter_name", field_name="username") + + class Meta: + model = AccountDB + fields = SHARED_FIELDS + ["username", "db_is_connected", "db_is_bot"] + + +class ScriptDBFilterSet(BaseTypeclassFilterSet): + """This adds filters for Script objects""" + class Meta: + model = ScriptDB + fields = SHARED_FIELDS + ["db_desc", "db_obj__db_key", "db_obj__id", "db_account__id", + "db_account__username", "db_is_active", "db_persistent", "db_interval"] diff --git a/evennia/web/api/permissions.py b/evennia/web/api/permissions.py new file mode 100644 index 0000000000..b68fa0769c --- /dev/null +++ b/evennia/web/api/permissions.py @@ -0,0 +1,85 @@ +from rest_framework import permissions + +from django.conf import settings + + +class EvenniaPermission(permissions.BasePermission): + """ + A Django Rest Framework permission class that allows us to use + Evennia's permission structure. Based on the action in a given + view, we'll check a corresponding Evennia access/lock check. + """ + # subclass this to change these permissions + MINIMUM_LIST_PERMISSION = settings.REST_FRAMEWORK.get("DEFAULT_LIST_PERMISSION", "builder") + MINIMUM_CREATE_PERMISSION = settings.REST_FRAMEWORK.get("DEFAULT_CREATE_PERMISSION", "builder") + view_locks = settings.REST_FRAMEWORK.get("DEFAULT_VIEW_LOCKS", ["examine"]) + destroy_locks = settings.REST_FRAMEWORK.get("DEFAULT_DESTROY_LOCKS", ["delete"]) + update_locks = settings.REST_FRAMEWORK.get("DEFAULT_UPDATE_LOCKS", ["control", "edit"]) + + def has_permission(self, request, view): + """Checks for permissions + + Args: + request (Request): The incoming request object. + view (View): The django view we are checking permission for. + + Returns: + bool: If permission is granted or not. If we return False here, a PermissionDenied + error will be raised from the view. + + Notes: + This method is a check that always happens first. If there's an object involved, + such as with retrieve, update, or delete, then the has_object_permission method + is called after this, assuming this returns `True`. + """ + # Only allow authenticated users to call the API + if not request.user.is_authenticated: + return False + if request.user.is_superuser: + return True + # these actions don't support object-level permissions, so use the above definitions + if view.action == "list": + return request.user.has_permistring(self.MINIMUM_LIST_PERMISSION) + if view.action == "create": + return request.user.has_permistring(self.MINIMUM_CREATE_PERMISSION) + return True # this means we'll check object-level permissions + + @staticmethod + def check_locks(obj, user, locks): + """Checks access for user for object with given locks + Args: + obj: Object instance we're checking + user (Account): User who we're checking permissions + locks (list): list of lockstrings + + Returns: + bool: True if they have access, False if they don't + """ + return any([obj.access(user, lock) for lock in locks]) + + def has_object_permission(self, request, view, obj): + """Checks object-level permissions after has_permission + + Args: + request (Request): The incoming request object. + view (View): The django view we are checking permission for. + obj: Object we're checking object-level permissions for + + Returns: + bool: If permission is granted or not. If we return False here, a PermissionDenied + error will be raised from the view. + + Notes: + This method assumes that has_permission has already returned True. We check + equivalent Evennia permissions in the request.user to determine if they can + complete the action. + """ + if view.action in ("list", "retrieve"): + # access_type is based on the examine command + return self.check_locks(obj, request.user, self.view_locks) + if view.action == "destroy": + # access type based on the destroy command + return self.check_locks(obj, request.user, self.destroy_locks) + if view.action in ("update", "partial_update", "set_attribute"): + # access type based on set command + return self.check_locks(obj, request.user, self.update_locks) diff --git a/evennia/web/api/serializers.py b/evennia/web/api/serializers.py new file mode 100644 index 0000000000..6fab237da4 --- /dev/null +++ b/evennia/web/api/serializers.py @@ -0,0 +1,207 @@ +""" +Serializers in the Django Rest Framework are similar to Forms in normal django. +They're used for transmitting and validating data, both going to clients and +coming to the server. However, where forms often contained presentation logic, +such as specifying widgets to use for selection, serializers typically leave +those decisions in the hands of clients, and are more focused on converting +data from the server to JSON (serialization) for a response, and validating +and converting JSON data sent from clients to our enpoints into python objects, +often django model instances, that we can use (deserialization). +""" + +from rest_framework import serializers + +from evennia.objects.objects import DefaultObject +from evennia.accounts.accounts import DefaultAccount +from evennia.scripts.models import ScriptDB +from evennia.typeclasses.attributes import Attribute +from evennia.typeclasses.tags import Tag + + +class AttributeSerializer(serializers.ModelSerializer): + value_display = serializers.SerializerMethodField(source="value") + db_value = serializers.CharField(write_only=True, required=False) + + class Meta: + model = Attribute + fields = ["db_key", "db_category", "db_attrtype", "value_display", "db_value"] + + @staticmethod + def get_value_display(obj: Attribute) -> str: + """ + Gets the string display of an Attribute's value for serialization + Args: + obj: Attribute being serialized + + Returns: + The Attribute's value in string format + """ + if obj.db_strvalue: + return obj.db_strvalue + return str(obj.value) + + +class TagSerializer(serializers.ModelSerializer): + class Meta: + model = Tag + fields = ["db_key", "db_category", "db_data", "db_tagtype"] + + +class SimpleObjectDBSerializer(serializers.ModelSerializer): + class Meta: + model = DefaultObject + fields = ["id", "db_key"] + + +class TypeclassSerializerMixin(object): + """Mixin that contains types shared by typeclasses. A note about tags, aliases, and permissions. You + might note that the methods and fields are defined here, but they're included explicitly in each child + class. What gives? It's a DRF error: serializer method fields which are inherited do not resolve correctly + in child classes, and as of this current version (3.11) you must have them in the child classes explicitly + to avoid field errors. Similarly, the child classes must contain the attribute serializer explicitly to + not have them render PK-related fields. + """ + + shared_fields = ["id", "db_key", "attributes", "db_typeclass_path", "aliases", "tags", "permissions"] + + @staticmethod + def get_tags(obj): + """ + Serializes tags from the object's Tagshandler + Args: + obj: Typeclassed object being serialized + + Returns: + List of TagSerializer data + """ + return TagSerializer(obj.tags.get(return_tagobj=True, return_list=True), many=True).data + + @staticmethod + def get_aliases(obj): + """ + Serializes tags from the object's Aliashandler + Args: + obj: Typeclassed object being serialized + + Returns: + List of TagSerializer data + """ + return TagSerializer(obj.aliases.get(return_tagobj=True, return_list=True), many=True).data + + @staticmethod + def get_permissions(obj): + """ + Serializes tags from the object's Permissionshandler + Args: + obj: Typeclassed object being serialized + + Returns: + List of TagSerializer data + """ + return TagSerializer(obj.permissions.get(return_tagobj=True, return_list=True), many=True).data + + @staticmethod + def get_attributes(obj): + """ + Serializes attributes from the object's AttributeHandler + Args: + obj: Typeclassed object being serialized + + Returns: + List of AttributeSerializer data + """ + return AttributeSerializer(obj.attributes.all(), many=True).data + + @staticmethod + def get_nicks(obj): + """ + Serializes attributes from the object's NicksHandler + Args: + obj: Typeclassed object being serialized + + Returns: + List of AttributeSerializer data + """ + return AttributeSerializer(obj.nicks.all(), many=True).data + + +class ObjectDBSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): + attributes = serializers.SerializerMethodField() + nicks = serializers.SerializerMethodField() + contents = serializers.SerializerMethodField() + exits = serializers.SerializerMethodField() + tags = serializers.SerializerMethodField() + aliases = serializers.SerializerMethodField() + permissions = serializers.SerializerMethodField() + + class Meta: + model = DefaultObject + fields = ["db_location", "db_home", "contents", "exits", "nicks"] + TypeclassSerializerMixin.shared_fields + read_only_fields = ["id"] + + @staticmethod + def get_exits(obj): + """ + Gets exits for the object + Args: + obj: Object being serialized + + Returns: + List of data from SimpleObjectDBSerializer + """ + exits = [ob for ob in obj.contents if ob.destination] + return SimpleObjectDBSerializer(exits, many=True).data + + @staticmethod + def get_contents(obj): + """ + Gets non-exits for the object + Args: + obj: Object being serialized + + Returns: + List of data from SimpleObjectDBSerializer + """ + non_exits = [ob for ob in obj.contents if not ob.destination] + return SimpleObjectDBSerializer(non_exits, many=True).data + + +class AccountSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): + """This uses the DefaultAccount object to have access to the sessions property""" + attributes = serializers.SerializerMethodField() + nicks = serializers.SerializerMethodField() + db_key = serializers.CharField(required=False) + session_ids = serializers.SerializerMethodField() + tags = serializers.SerializerMethodField() + aliases = serializers.SerializerMethodField() + permissions = serializers.SerializerMethodField() + + @staticmethod + def get_session_ids(obj): + """ + Gets a list of session IDs connected to this Account + Args: + obj (DefaultAccount): Account we're grabbing sessions from + + Returns: + List of session IDs + """ + return [sess.sessid for sess in obj.sessions.all() if hasattr(sess, "sessid")] + + class Meta: + model = DefaultAccount + fields = ["username", "session_ids", "nicks"] + TypeclassSerializerMixin.shared_fields + read_only_fields = ["id"] + + +class ScriptDBSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): + attributes = serializers.SerializerMethodField() + tags = serializers.SerializerMethodField() + aliases = serializers.SerializerMethodField() + permissions = serializers.SerializerMethodField() + + class Meta: + model = ScriptDB + fields = ["db_interval", "db_persistent", "db_start_delay", + "db_is_active", "db_repeats"] + TypeclassSerializerMixin.shared_fields + read_only_fields = ["id"] diff --git a/evennia/web/api/tests.py b/evennia/web/api/tests.py new file mode 100644 index 0000000000..4e17b424b9 --- /dev/null +++ b/evennia/web/api/tests.py @@ -0,0 +1,145 @@ +"""Tests for the REST API""" +from evennia.utils.test_resources import EvenniaTest +from evennia.web.api import serializers +from rest_framework.test import APIClient +from django.urls import reverse +from django.test import override_settings +from collections import namedtuple +from django.conf.urls import url, include +from django.core.exceptions import ObjectDoesNotExist + +urlpatterns = [ + url(r"^", include("evennia.web.website.urls")), + url(r"^api/", include("evennia.web.api.urls", namespace="api")), +] + + +@override_settings( + REST_API_ENABLED=True, ROOT_URLCONF=__name__, AUTH_USERNAME_VALIDATORS=[] +) +class TestEvenniaRESTApi(EvenniaTest): + client_class = APIClient + maxDiff = None + + def setUp(self): + super().setUp() + self.account.is_superuser = True + self.account.save() + self.client.force_login(self.account) + # scripts do not have default locks. Without them, even superuser access check fails + self.script.locks.add("edit: perm(Admin); examine: perm(Admin); delete: perm(Admin)") + + def tearDown(self): + try: + super().tearDown() + except ObjectDoesNotExist: + pass + + def get_view_details(self, action): + """Helper function for generating list of named tuples""" + View = namedtuple("View", ["view_name", "obj", "list", "serializer", "create_data", "retrieve_data"]) + views = [ + View("object-%s" % action, self.obj1, [self.obj1, self.char1, self.exit, self.room1, self.room2, self.obj2, + self.char2], serializers.ObjectDBSerializer, + {"db_key": "object-create-test-name"}, + serializers.ObjectDBSerializer(self.obj1).data), + View("character-%s" % action, self.char1, [self.char1, self.char2], serializers.ObjectDBSerializer, + {"db_key": "character-create-test-name"}, + serializers.ObjectDBSerializer(self.char1).data), + View("exit-%s" % action, self.exit, [self.exit], serializers.ObjectDBSerializer, + {"db_key": "exit-create-test-name"}, + serializers.ObjectDBSerializer(self.exit).data), + View("room-%s" % action, self.room1, [self.room1, self.room2], serializers.ObjectDBSerializer, + {"db_key": "room-create-test-name"}, + serializers.ObjectDBSerializer(self.room1).data), + View("script-%s" % action, self.script, [self.script], serializers.ScriptDBSerializer, + {"db_key": "script-create-test-name"}, + serializers.ScriptDBSerializer(self.script).data), + View("account-%s" % action, self.account2, [self.account, self.account2], serializers.AccountSerializer, + {"username": "account-create-test-name"}, + serializers.AccountSerializer(self.account2).data), + ] + return views + + def test_retrieve(self): + views = self.get_view_details("detail") + for view in views: + with self.subTest(msg="Testing {} retrieve".format(view.view_name)): + view_url = reverse( + "api:{}".format(view.view_name), kwargs={"pk": view.obj.pk} + ) + response = self.client.get(view_url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.data, view.retrieve_data) + + def test_update(self): + views = self.get_view_details("detail") + for view in views: + with self.subTest(msg="Testing {} update".format(view.view_name)): + view_url = reverse( + "api:{}".format(view.view_name), kwargs={"pk": view.obj.pk} + ) + # test both PUT (update) and PATCH (partial update) here + for new_key, method in (("foobar", "put"), ("fizzbuzz", "patch")): + field = "username" if "account" in view.view_name else "db_key" + data = {field: new_key} + response = getattr(self.client, method)(view_url, data=data) + self.assertEqual(response.status_code, 200) + view.obj.refresh_from_db() + self.assertEqual(getattr(view.obj, field), new_key) + self.assertEqual(response.data[field], new_key) + + def test_delete(self): + views = self.get_view_details("detail") + for view in views: + with self.subTest(msg="Testing {} delete".format(view.view_name)): + view_url = reverse( + "api:{}".format(view.view_name), kwargs={"pk": view.obj.pk} + ) + response = self.client.delete(view_url) + self.assertEqual(response.status_code, 204) + with self.assertRaises(ObjectDoesNotExist): + view.obj.refresh_from_db() + + def test_list(self): + views = self.get_view_details("list") + for view in views: + with self.subTest(msg=f"Testing {view.view_name} "): + view_url = reverse(f"api:{view.view_name}") + response = self.client.get(view_url) + self.assertEqual(response.status_code, 200) + self.assertCountEqual(response.data['results'], [view.serializer(obj).data for obj in view.list]) + + def test_create(self): + views = self.get_view_details("list") + for view in views: + with self.subTest(msg=f"Testing {view.view_name} create"): + # create is a POST request off of -list + view_url = reverse(f"api:{view.view_name}") + # check failures from not sending required fields + response = self.client.post(view_url) + self.assertEqual(response.status_code, 400) + # check success when sending the required data + response = self.client.post(view_url, data=view.create_data) + self.assertEqual(response.status_code, 201, f"Response was {response.data}") + + def test_set_attribute(self): + views = self.get_view_details("set-attribute") + for view in views: + with self.subTest(msg=f"Testing {view.view_name}"): + view_url = reverse(f"api:{view.view_name}", kwargs={"pk": view.obj.pk}) + # check failures from not sending required fields + response = self.client.post(view_url) + self.assertEqual(response.status_code, 400, f"Response was: {response.data}") + # test adding an attribute + self.assertEqual(view.obj.db.some_test_attr, None) + attr_name = "some_test_attr" + attr_data = {"db_key": attr_name, "db_value": "test_value"} + response = self.client.post(view_url, data=attr_data) + self.assertEqual(response.status_code, 200, f"Response was: {response.data}") + self.assertEquals(view.obj.attributes.get(attr_name), "test_value") + # now test removing it + attr_data = {"db_key": attr_name} + response = self.client.post(view_url, data=attr_data) + self.assertEqual(response.status_code, 200, f"Response was: {response.data}") + self.assertEquals(view.obj.attributes.get(attr_name), None) diff --git a/evennia/web/api/urls.py b/evennia/web/api/urls.py new file mode 100644 index 0000000000..0594e26d16 --- /dev/null +++ b/evennia/web/api/urls.py @@ -0,0 +1,39 @@ +""" +The Django Rest Framework provides a way of generating urls for different +views that implement standard CRUD operations in a quick way, using 'routers' +and 'viewsets'. A viewset implements standard CRUD actions and any custom actions +that you want, and then a router will automatically generate URLs based on the +actions that it detects for a viewset. For example, below we create a DefaultRouter. +We then register ObjectDBViewSet, a viewset for CRUD operations for ObjectDB +instances, to the 'objects' base endpoint. That will generate a number of URLs +like the following: +list objects: action: GET, url: /objects/, view name: object-list +create object: action: POST, url: /objects/, view name: object-list +retrieve object: action: GET, url: /objects/<:pk>, view name: object-detail +update object: action: POST, url: /objects/<:pk>, view name: object-detail +delete object: action: DELETE, url: /objects/<:pk>, view name: object-detail +set attribute: action: POST, url: /objects/<:pk>/set-attribute, view name: object-set-attribute +""" + +from rest_framework import routers +from evennia.web.api.views import ( + ObjectDBViewSet, + AccountDBViewSet, + CharacterViewSet, + ExitViewSet, + RoomViewSet, + ScriptDBViewSet +) + +app_name = "api" + +router = routers.DefaultRouter() +router.trailing_slash = "/?" +router.register(r'accounts', AccountDBViewSet, basename="account") +router.register(r'objects', ObjectDBViewSet, basename="object") +router.register(r'characters', CharacterViewSet, basename="character") +router.register(r'exits', ExitViewSet, basename="exit") +router.register(r'rooms', RoomViewSet, basename="room") +router.register(r'scripts', ScriptDBViewSet, basename="script") + +urlpatterns = router.urls diff --git a/evennia/web/api/views.py b/evennia/web/api/views.py new file mode 100644 index 0000000000..1c5988f1db --- /dev/null +++ b/evennia/web/api/views.py @@ -0,0 +1,106 @@ +""" +Views are the functions that are called by different url endpoints. +The Django Rest Framework provides collections called 'ViewSets', which +can generate a number of views for the common CRUD operations. +""" +from rest_framework.viewsets import ModelViewSet +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework import status + +from django_filters.rest_framework import DjangoFilterBackend + +from evennia.objects.models import ObjectDB +from evennia.objects.objects import DefaultCharacter, DefaultExit, DefaultRoom +from evennia.accounts.models import AccountDB +from evennia.scripts.models import ScriptDB +from evennia.web.api.serializers import ObjectDBSerializer, AccountSerializer, ScriptDBSerializer, AttributeSerializer +from evennia.web.api.filters import ObjectDBFilterSet, AccountDBFilterSet, ScriptDBFilterSet +from evennia.web.api.permissions import EvenniaPermission + + +class TypeclassViewSetMixin(object): + """ + This mixin adds some shared functionality to each viewset of a typeclass. They all use the same + permission classes and filter backend. You can override any of these in your own viewsets. + """ + # permission classes determine who is authorized to call the view + permission_classes = [EvenniaPermission] + # the filter backend allows for retrieval views to have filter arguments passed to it, + # for example: mygame.com/api/objects?db_key=bob to find matches based on objects having a db_key of bob + filter_backends = [DjangoFilterBackend] + + @action(detail=True, methods=["put", "post"]) + def set_attribute(self, request, pk=None): + """ + This is an example of a custom action added to a viewset. Based on the name of the + method, it will create a default url_name (used for reversing) and url_path. + The 'pk' argument is automatically passed to this action because it has a url path + of the format /:pk/set-attribute. The get_object method is automatically + set in the expected viewset classes that will inherit this, using the pk that's + passed along to retrieve the object. + + This action will set an attribute if the db_value is defined, or remove it + if no db_value is provided. + """ + attr = AttributeSerializer(data=request.data) + obj = self.get_object() + if attr.is_valid(raise_exception=True): + key = attr.validated_data["db_key"] + value = attr.validated_data.get("db_value") + category = attr.validated_data.get("db_category") + attr_type = attr.validated_data.get("db_attrtype") + if attr_type == "nick": + handler = obj.nicks + else: + handler = obj.attributes + if value: + handler.add(key=key, value=value, category=category) + else: + handler.remove(key=key, category=category) + return Response(AttributeSerializer(obj.db_attributes.all(), many=True).data, status=status.HTTP_200_OK) + return Response(attr.errors, status=status.HTTP_400_BAD_REQUEST) + + +class ObjectDBViewSet(TypeclassViewSetMixin, ModelViewSet): + """ + An example of a basic viewset for all ObjectDB instances. It declares the + serializer to use for both retrieving and changing/creating/deleting + instances. Serializers are similar to django forms, used for the + transmitting of data (typically json). + """ + serializer_class = ObjectDBSerializer + queryset = ObjectDB.objects.all() + filterset_class = ObjectDBFilterSet + + +class CharacterViewSet(ObjectDBViewSet): + """ + This overrides the queryset to only retrieve Character objects + based on your DefaultCharacter typeclass path. + """ + queryset = DefaultCharacter.objects.typeclass_search(DefaultCharacter.path, include_children=True) + + +class RoomViewSet(ObjectDBViewSet): + """Viewset for Room objects""" + queryset = DefaultRoom.objects.typeclass_search(DefaultRoom.path, include_children=True) + + +class ExitViewSet(ObjectDBViewSet): + """Viewset for Exit objects""" + queryset = DefaultExit.objects.typeclass_search(DefaultExit.path, include_children=True) + + +class AccountDBViewSet(TypeclassViewSetMixin, ModelViewSet): + """Viewset for Account objects""" + serializer_class = AccountSerializer + queryset = AccountDB.objects.all() + filterset_class = AccountDBFilterSet + + +class ScriptDBViewSet(TypeclassViewSetMixin, ModelViewSet): + """Viewset for Script objects""" + serializer_class = ScriptDBSerializer + queryset = ScriptDB.objects.all() + filterset_class = ScriptDBFilterSet diff --git a/evennia/web/urls.py b/evennia/web/urls.py index a617a6289f..6fce632ca1 100644 --- a/evennia/web/urls.py +++ b/evennia/web/urls.py @@ -6,6 +6,8 @@ # http://diveintopython.org/regular_expressions/street_addresses.html#re.matching.2.3 # +from django.conf.urls import url +from django.conf import settings from django.urls import path, include from django.views.generic import RedirectView @@ -20,3 +22,6 @@ urlpatterns = [ # favicon path("favicon.ico", RedirectView.as_view(url="/media/images/favicon.ico", permanent=False)), ] + +if settings.REST_API_ENABLED: + urlpatterns += [url(r'^api/', include("evennia.web.api.urls", namespace="api"))] diff --git a/requirements.txt b/requirements.txt index e569a8ac07..9298e62ffd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,12 @@ # Evennia dependencies # general +attrs >= 19.2.0 django >= 2.2.5, < 2.3 twisted >= 19.2.1, < 20.0.0 pytz +djangorestframework >= 3.10.3, < 3.12 +django-filter >= 2.2.0, < 2.3 django-sekizai inflect autobahn >= 17.9.3