From 14f91ee7afdc07f1a604de75770f2996fdd3ef09 Mon Sep 17 00:00:00 2001 From: Scott Cruwys Date: Sat, 2 Apr 2022 13:05:22 -0400 Subject: [PATCH] feat(api): Add base logic for API --- app/api/migrations/0001_initial.py | 5 +- app/api/mixins.py | 1 - app/api/models.py | 15 ++- app/api/permissions.py | 58 --------- app/api/v1/customfields/__init__.py | 0 .../properties.py} | 15 ++- app/api/v1/definitions/__init__.py | 0 app/api/v1/definitions/columns.py | 82 ++++++++++++ app/api/v1/definitions/datastores.py | 71 ++++++++++ app/api/v1/definitions/schemas.py | 80 ++++++++++++ app/api/v1/definitions/tables.py | 81 ++++++++++++ app/api/v1/exceptions.py | 23 ++++ app/api/v1/pagination.py | 79 +++++++++++ app/api/v1/permissions.py | 16 +++ app/api/v1/serializers.py | 10 ++ app/api/v1/throttling.py | 23 ++++ app/api/v1/urls.py | 54 +++++++- app/api/v1/views.py | 123 ++++++++++++++++++ metamapper/settings.py | 12 +- 19 files changed, 680 insertions(+), 68 deletions(-) delete mode 100644 app/api/mixins.py delete mode 100644 app/api/permissions.py create mode 100644 app/api/v1/customfields/__init__.py rename app/api/v1/{definitions.py => customfields/properties.py} (51%) create mode 100644 app/api/v1/definitions/__init__.py create mode 100644 app/api/v1/definitions/columns.py create mode 100644 app/api/v1/definitions/datastores.py create mode 100644 app/api/v1/definitions/schemas.py create mode 100644 app/api/v1/definitions/tables.py create mode 100644 app/api/v1/exceptions.py create mode 100644 app/api/v1/pagination.py create mode 100644 app/api/v1/permissions.py create mode 100644 app/api/v1/serializers.py create mode 100644 app/api/v1/throttling.py create mode 100644 app/api/v1/views.py diff --git a/app/api/migrations/0001_initial.py b/app/api/migrations/0001_initial.py index 4295429f..b07ec8b5 100644 --- a/app/api/migrations/0001_initial.py +++ b/app/api/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.10 on 2022-04-01 11:54 +# Generated by Django 3.0.10 on 2022-04-01 21:16 from django.db import migrations, models import django.db.models.deletion @@ -10,8 +10,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('authorization', '0001_initial'), ('authentication', '0001_initial'), + ('authorization', '0001_initial'), ] operations = [ @@ -24,6 +24,7 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=60)), ('is_enabled', models.BooleanField(default=True)), ('token', utils.encrypt.fields.EncryptedCharField(max_length=32)), + ('last_used_at', models.DateTimeField(default=None, null=True)), ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to='authentication.Workspace')), ], options={ diff --git a/app/api/mixins.py b/app/api/mixins.py deleted file mode 100644 index 40a96afc..00000000 --- a/app/api/mixins.py +++ /dev/null @@ -1 +0,0 @@ -# -*- coding: utf-8 -*- diff --git a/app/api/models.py b/app/api/models.py index 79a45f6d..f65d6c5e 100644 --- a/app/api/models.py +++ b/app/api/models.py @@ -1,8 +1,12 @@ # -*- coding: utf-8 -*- -from django.db import models from base64 import b64encode +from datetime import timedelta + +from django.db import models +from django.utils import timezone from app.authentication.models import Workspace + from utils.encrypt.fields import EncryptedCharField from utils.mixins.models import StringPrimaryKeyModel, TimestampedModel @@ -22,9 +26,18 @@ class ApiToken(StringPrimaryKeyModel, TimestampedModel): token = EncryptedCharField(max_length=32, null=False, blank=False) + last_used_at = models.DateTimeField(default=None, null=True) + class Meta: db_table = 'api_token' unique_together = ('workspace', 'name',) def get_secret(self): return b64encode(':'.join([self.id, self.token]).encode('utf-8')).decode() + + def touch(self): + now = timezone.now() + if self.last_used_at and (now - self.last_used_at) <= timedelta(seconds=15): + return + self.last_used_at = now + self.save() diff --git a/app/api/permissions.py b/app/api/permissions.py deleted file mode 100644 index 9d98080d..00000000 --- a/app/api/permissions.py +++ /dev/null @@ -1,58 +0,0 @@ -# -*- coding: utf-8 -*- -from base64 import b64decode -from rest_framework import permissions -from rest_framework.exceptions import PermissionDenied - -from app.api.models import ApiToken -from app.authentication.models import Workspace - - -class IsAuthenticated(permissions.BasePermission): - """Standard authentication scheme for the API integration. - """ - def has_permission(self, request, view): - """Check if the request is authenticated. - """ - workspace = self.get_workspace(request) - - if not workspace: - raise PermissionDenied(detail='Invalid credentials') - - api_token = self.get_api_token(request, workspace) - - if not api_token: - raise PermissionDenied(detail='Invalid credentials') - - return True - - def get_workspace(self, request): - """Retrieve the Workspace from the headers. - """ - workspace_id = request.META.get('HTTP_X_WORKSPACE_ID') - - if not workspace_id: - return None - - return Workspace.objects.filter(id=workspace_id).first() - - def get_api_token(self, request, workspace): - """Retrieve the ApiToken from the headers. - """ - authorization = request.META.get('HTTP_AUTHORIZATION') - - if not authorization: - return None - - secret = ''.join(authorization.split()[1:]) - token_parts = b64decode(secret.encode()).decode().split(':') - - if len(token_parts) != 2: - return None - - api_token = ApiToken.objects.filter( - id=token_parts[0], - workspace_id=workspace.id, - ).first() - - if api_token and api_token.token == token_parts[1]: - return api_token diff --git a/app/api/v1/customfields/__init__.py b/app/api/v1/customfields/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/api/v1/definitions.py b/app/api/v1/customfields/properties.py similarity index 51% rename from app/api/v1/definitions.py rename to app/api/v1/customfields/properties.py index f1641eaf..e51bc3cc 100644 --- a/app/api/v1/definitions.py +++ b/app/api/v1/customfields/properties.py @@ -7,7 +7,20 @@ @api_view(['GET']) @permission_classes([IsAuthenticated]) -def example_view(request, format=None): +def get_properties(request, format=None): + """GET /api/v1/properties + """ + content = { + 'status': 'request was permitted' + } + return Response(content) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_property(request, format=None): + """GET /api/v1/properties/:id + """ content = { 'status': 'request was permitted' } diff --git a/app/api/v1/definitions/__init__.py b/app/api/v1/definitions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/api/v1/definitions/columns.py b/app/api/v1/definitions/columns.py new file mode 100644 index 00000000..d65fc7a9 --- /dev/null +++ b/app/api/v1/definitions/columns.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +from rest_framework import serializers + +from app.api.v1.exceptions import NotFound +from app.api.v1.serializers import ApiSerializer +from app.api.v1.views import DetailAPIView, FindAPIView, ListAPIView +from app.definitions.models import Column + + +class ColumnSerializer(ApiSerializer): + id = serializers.SerializerMethodField() + + tags = serializers.ListField( + child=serializers.CharField(max_length=30), + max_length=10, + allow_empty=True, + allow_null=True, + required=False, + ) + + class Meta: + model = Column + fields = [ + 'id', + 'name', + 'tags', + 'created_at', + 'updated_at', + ] + writable_fields = ['tags'] + + def get_id(self, obj): + return obj.object_id + + def validate_tags(self, tags): + return list(set(tags)) if isinstance(tags, (list,)) else [] + + def update(self, instance, validated_data): + instance.tags = validated_data.get('tags', instance.tags) + instance.save() + return instance + + +class ColumnList(ListAPIView): + serializer_class = ColumnSerializer + + def get_queryset(self): + filter_kwargs = { + 'table_id': self.kwargs['table_id'], + 'workspace': self.request.workspace, + } + return Column.objects.filter(**filter_kwargs) + + +class ColumnDetail(DetailAPIView): + serializer_class = ColumnSerializer + + def get_object(self, pk): + get_kwargs = { + 'workspace': self.request.workspace, + 'object_id': pk, + } + try: + return Column.objects.get(**get_kwargs) + except Column.DoesNotExist: + raise NotFound() + + +class ColumnFind(FindAPIView): + serializer_class = ColumnSerializer + + required_query_params = ['schema', 'table', 'name'] + + def find_object(self, datastore_id, query_params): + filter_kwargs = { + 'workspace': self.request.workspace, + 'table__schema__datastore_id': datastore_id, + 'table__schema__name': query_params['schema'], + 'table__name': query_params['table'], + 'name': query_params['name'], + } + return Column.objects.filter(**filter_kwargs).first() diff --git a/app/api/v1/definitions/datastores.py b/app/api/v1/definitions/datastores.py new file mode 100644 index 00000000..5055cd42 --- /dev/null +++ b/app/api/v1/definitions/datastores.py @@ -0,0 +1,71 @@ +# # -*- coding: utf-8 -*- +# from rest_framework.decorators import api_view, permission_classes +# from rest_framework.response import Response + +# from app.api.permissions import IsAuthenticated + + +# @api_view(['GET']) +# @permission_classes([IsAuthenticated]) +# def get_datastores(request, format=None): +# """GET /api/v1/datastores +# """ +# content = { +# 'status': 'request was permitted' +# } +# return Response(content) + + +# @api_view(['GET']) +# @permission_classes([IsAuthenticated]) +# def get_datastore(request, format=None): +# """GET /api/v1/datastores/:datastoreId +# """ +# content = { +# 'status': 'request was permitted' +# } +# return Response(content) + + +# @api_view(['GET']) +# @permission_classes([IsAuthenticated]) +# def get_datastore_tables(request, format=None): +# """GET /api/v1/datastores/:datastoreId/tables +# """ +# content = { +# 'status': 'request was permitted' +# } +# return Response(content) + + +# @api_view(['PATCH']) +# @permission_classes([IsAuthenticated]) +# def update_datastore(request, format=None): +# """PATCH /api/v1/datastores/:datastoreId +# """ +# content = { +# 'status': 'request was permitted' +# } +# return Response(content) + + +# @api_view(['PATCH']) +# @permission_classes([IsAuthenticated]) +# def update_datastore_properties(request, format=None): +# """PATCH /api/v1/datastores/:datastoreId/properties +# """ +# content = { +# 'status': 'request was permitted' +# } +# return Response(content) + + +# @api_view(['PATCH']) +# @permission_classes([IsAuthenticated]) +# def update_datastore_owners(request, format=None): +# """PATCH /api/v1/datastores/:datastoreId/owners +# """ +# content = { +# 'status': 'request was permitted' +# } +# return Response(content) diff --git a/app/api/v1/definitions/schemas.py b/app/api/v1/definitions/schemas.py new file mode 100644 index 00000000..35f34eb6 --- /dev/null +++ b/app/api/v1/definitions/schemas.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +from rest_framework import serializers + +from app.api.v1.exceptions import NotFound +from app.api.v1.serializers import ApiSerializer +from app.api.v1.views import DetailAPIView, FindAPIView, ListAPIView +from app.definitions.models import Schema + + +class SchemaSerializer(ApiSerializer): + id = serializers.SerializerMethodField() + + tags = serializers.ListField( + child=serializers.CharField(max_length=30), + max_length=10, + allow_empty=True, + allow_null=True, + required=False, + ) + + class Meta: + model = Schema + fields = [ + 'id', + 'name', + 'tags', + 'created_at', + 'updated_at', + ] + writable_fields = ['tags'] + + def get_id(self, obj): + return obj.object_id + + def validate_tags(self, tags): + return list(set(tags)) if isinstance(tags, (list,)) else [] + + def update(self, instance, validated_data): + instance.tags = validated_data.get('tags', instance.tags) + instance.save() + return instance + + +class SchemaList(ListAPIView): + serializer_class = SchemaSerializer + + def get_queryset(self): + filter_kwargs = { + 'datastore_id': self.kwargs['datastore_id'], + 'workspace': self.request.workspace, + } + return Schema.objects.filter(**filter_kwargs) + + +class SchemaDetail(DetailAPIView): + serializer_class = SchemaSerializer + + def get_object(self, pk): + get_kwargs = { + 'workspace': self.request.workspace, + 'object_id': pk, + } + try: + return Schema.objects.get(**get_kwargs) + except Schema.DoesNotExist: + raise NotFound() + + +class SchemaFind(FindAPIView): + serializer_class = SchemaSerializer + + required_query_params = ['name'] + + def find_object(self, datastore_id, query_params): + filter_kwargs = { + 'datastore_id': datastore_id, + 'workspace': self.request.workspace, + 'name': query_params['name'], + } + return Schema.objects.filter(**filter_kwargs).first() diff --git a/app/api/v1/definitions/tables.py b/app/api/v1/definitions/tables.py new file mode 100644 index 00000000..8cd714b3 --- /dev/null +++ b/app/api/v1/definitions/tables.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +from rest_framework import serializers + +from app.api.v1.exceptions import NotFound +from app.api.v1.serializers import ApiSerializer +from app.api.v1.views import DetailAPIView, FindAPIView, ListAPIView +from app.definitions.models import Table + + +class TableSerializer(ApiSerializer): + id = serializers.SerializerMethodField() + + tags = serializers.ListField( + child=serializers.CharField(max_length=30), + max_length=10, + allow_empty=True, + allow_null=True, + required=False, + ) + + class Meta: + model = Table + fields = [ + 'id', + 'name', + 'tags', + 'created_at', + 'updated_at', + ] + writable_fields = ['tags'] + + def get_id(self, obj): + return obj.object_id + + def validate_tags(self, tags): + return list(set(tags)) if isinstance(tags, (list,)) else [] + + def update(self, instance, validated_data): + instance.tags = validated_data.get('tags', instance.tags) + instance.save() + return instance + + +class TableList(ListAPIView): + serializer_class = TableSerializer + + def get_queryset(self): + filter_kwargs = { + 'schema_id': self.kwargs['schema_id'], + 'workspace': self.request.workspace, + } + return Table.objects.filter(**filter_kwargs) + + +class TableDetail(DetailAPIView): + serializer_class = TableSerializer + + def get_object(self, pk): + get_kwargs = { + 'workspace': self.request.workspace, + 'object_id': pk, + } + try: + return Table.objects.get(**get_kwargs) + except Table.DoesNotExist: + raise NotFound() + + +class TableFind(FindAPIView): + serializer_class = TableSerializer + + required_query_params = ['schema', 'name'] + + def find_object(self, datastore_id, query_params): + filter_kwargs = { + 'workspace': self.request.workspace, + 'schema__datastore_id': datastore_id, + 'schema__name': query_params['schema'], + 'name': query_params['name'], + } + return Table.objects.filter(**filter_kwargs).first() diff --git a/app/api/v1/exceptions.py b/app/api/v1/exceptions.py new file mode 100644 index 00000000..feab5bfd --- /dev/null +++ b/app/api/v1/exceptions.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +from django.utils.translation import gettext_lazy as _ + +from rest_framework import status +from rest_framework.exceptions import APIException + + +class PermissionDenied(APIException): + status_code = status.HTTP_403_FORBIDDEN + default_detail = _('You do not have permission to perform this action.') + default_code = 'forbidden' + + +class NotFound(APIException): + status_code = status.HTTP_404_NOT_FOUND + default_detail = _('Resource could not be found.') + default_code = 'not_found' + + +class ParameterValidationFailed(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = _('Parameter validation failed.') + default_code = 'parameter_validation' diff --git a/app/api/v1/pagination.py b/app/api/v1/pagination.py new file mode 100644 index 00000000..e0fd68f9 --- /dev/null +++ b/app/api/v1/pagination.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +from base64 import b64encode +from collections import OrderedDict +from urllib import parse + +from rest_framework.pagination import CursorPagination +from rest_framework.response import Response + + +class CursorSetPagination(CursorPagination): + page_size = 100 + page_size_query_param = 'page_size' + ordering = '-created_at' + count = None + + def paginate_queryset(self, queryset, request, view=None): + self.count = self.get_count(queryset) + return super().paginate_queryset(queryset, request, view) + + def get_count(self, queryset): + try: + return queryset.count() + except (AttributeError, TypeError): + return len(queryset) + + def get_paginated_response(self, data): + return Response(OrderedDict([ + ('next_page_token', self.get_next_link()), + ('prev_page_token', self.get_previous_link()), + ('page_info', self.get_page_info()), + ('items', data) + ])) + + def get_page_info(self): + return OrderedDict([ + ('total_results', self.count), + ('results_per_page', self.page_size), + ]) + + def get_paginated_response_schema(self, schema): + return { + 'type': 'object', + 'properties': { + 'next_page_token': { + 'type': 'string', + 'nullable': True, + }, + 'prev_page_token': { + 'type': 'string', + 'nullable': True, + }, + 'page_info': { + 'type': 'object', + 'properties': { + 'total_results': { + 'type': 'integer', + 'nullable': False, + }, + 'results_per_page': { + 'type': 'integer', + 'nullable': False, + }, + }, + }, + 'items': schema, + }, + } + + def encode_cursor(self, cursor): + tokens = {} + if cursor.offset != 0: + tokens['o'] = str(cursor.offset) + if cursor.reverse: + tokens['r'] = '1' + if cursor.position is not None: + tokens['p'] = cursor.position + + querystring = parse.urlencode(tokens, doseq=True) + return b64encode(querystring.encode('ascii')).decode('ascii') diff --git a/app/api/v1/permissions.py b/app/api/v1/permissions.py new file mode 100644 index 00000000..e81dd63f --- /dev/null +++ b/app/api/v1/permissions.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +from rest_framework import permissions + +from app.api.v1.exceptions import PermissionDenied + + +class IsAuthenticated(permissions.BasePermission): + """Standard authentication scheme for the API integration. + """ + def has_permission(self, request, view): + """Check if the request is authenticated. + """ + if not request.workspace or not request.api_token: + raise PermissionDenied() + + return True diff --git a/app/api/v1/serializers.py b/app/api/v1/serializers.py new file mode 100644 index 00000000..412977a0 --- /dev/null +++ b/app/api/v1/serializers.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +from rest_framework import serializers + + +class ApiSerializer(serializers.ModelSerializer): + def get_fields(self): + fields = super().get_fields() + for field_name, field in fields.items(): + field.read_only = field_name not in self.Meta.writable_fields + return fields diff --git a/app/api/v1/throttling.py b/app/api/v1/throttling.py new file mode 100644 index 00000000..e0f91d01 --- /dev/null +++ b/app/api/v1/throttling.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +from django.core.cache import caches + +from rest_framework import throttling + + +class ApiTokenThrottle(throttling.SimpleRateThrottle): + cache = caches['api_throttle'] + scope = 'api_token' + + def get_cache_key(self, request, view): + if request.api_token: + ident = request.api_token.id + else: + ident = self.get_ident(request) + + return self.cache_format % { + 'scope': self.scope, + 'ident': ident + } + + def get_rate(self): + return '1/min' diff --git a/app/api/v1/urls.py b/app/api/v1/urls.py index 6cffe237..819154a3 100644 --- a/app/api/v1/urls.py +++ b/app/api/v1/urls.py @@ -1,13 +1,59 @@ # -*- coding: utf-8 -*- from django.conf.urls import url -from app.api.v1 import definitions +from rest_framework.decorators import api_view + +from app.api.v1.exceptions import NotFound + +from app.api.v1.definitions import datastores +from app.api.v1.definitions import schemas +from app.api.v1.definitions import tables +from app.api.v1.definitions import columns + + +@api_view(['GET']) +def not_found(request, format=None): + """Default "404 - Not Found" response for when routes are not defined. + """ + raise NotFound() urlpatterns = [ url( - r'^test/?$', - definitions.example_view, - name='test' + r'^datastores/(?P[0-9a-zA-Z]{12})/schemas/find/?$', + schemas.SchemaFind.as_view(), + ), + url( + r'^datastores/(?P[0-9a-zA-Z]{12})/schemas/?$', + schemas.SchemaList.as_view(), + ), + url( + r'^schemas/(?P[a-f0-9]{32})/?$', + schemas.SchemaDetail.as_view(), + ), + url( + r'^datastores/(?P[0-9a-zA-Z]{12})/tables/find/?$', + tables.TableFind.as_view(), + ), + url( + r'^schemas/(?P[a-f0-9]{32})/tables/?$', + tables.TableList.as_view(), + ), + url( + r'^tables/(?P[a-f0-9]{32})/?$', + tables.TableDetail.as_view(), + ), + url( + r'^datastores/(?P[0-9a-zA-Z]{12})/columns/find/?$', + columns.ColumnFind.as_view(), + ), + url( + r'^tables/(?P[a-f0-9]{32})/columns/?$', + columns.ColumnList.as_view(), + ), + url( + r'^columns/(?P[a-f0-9]{32})/?$', + columns.ColumnDetail.as_view(), ), + url(r'', not_found), ] diff --git a/app/api/v1/views.py b/app/api/v1/views.py new file mode 100644 index 00000000..6b0f88ea --- /dev/null +++ b/app/api/v1/views.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +from base64 import b64decode +from binascii import Error + +from rest_framework import generics, views, status +from rest_framework.response import Response + +from app.api.models import ApiToken +from app.authentication.models import Workspace + +from app.api.v1.exceptions import ParameterValidationFailed, NotFound +from app.api.v1.pagination import CursorSetPagination +from app.api.v1.permissions import IsAuthenticated +from app.api.v1.throttling import ApiTokenThrottle + + +class BaseView(object): + """Scope ViewSet requests to the provided Workspace. + """ + throttle_classes = [ApiTokenThrottle] + + def dispatch(self, request, *args, **kwargs): + """Set the request context before moving forward. + """ + request.workspace = self.get_workspace(request) + request.api_token = self.get_api_token(request, request.workspace) + + return super().dispatch(request, *args, **kwargs) + + def get_workspace(self, request): + """Retrieve the Workspace from the headers. + """ + workspace_id = request.META.get('HTTP_X_WORKSPACE_ID') + + if not workspace_id: + return None + + return Workspace.objects.filter(id=workspace_id).first() + + def get_api_token(self, request, workspace): + """Retrieve the ApiToken from the headers. + """ + authorization = request.META.get('HTTP_AUTHORIZATION') + + if not authorization or not workspace: + return None + + secret = ''.join(authorization.split()[1:]) + + try: + token_parts = b64decode(secret.encode()).decode().split(':') + except Error: + return None + + if len(token_parts) != 2: + return None + + api_token = ApiToken.objects.filter( + id=token_parts[0], + workspace_id=workspace.id, + ).first() + + if api_token and api_token.token == token_parts[1]: + api_token.touch() + return api_token + + +class DetailAPIView(BaseView, views.APIView): + """Base API view for detail requests. + """ + permission_classes = [IsAuthenticated] + + def format_response(self, data, *args, **kwargs): + return Response(data, *args, **kwargs) + + def get(self, request, pk, format=None): + instance = self.get_object(pk) + serializer = self.serializer_class(instance) + return self.format_response(serializer.data) + + def patch(self, request, pk): + instance = self.get_object(pk) + serializer = self.serializer_class( + instance, + data=request.data, + partial=True) + if serializer.is_valid(): + serializer.save() + return self.format_response(serializer.data) + return self.format_response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class ListAPIView(BaseView, generics.ListAPIView): + """Base API view for list requests. + """ + pagination_class = CursorSetPagination + permission_classes = [IsAuthenticated] + + +class FindAPIView(BaseView, views.APIView): + """Base API view for find requests. + """ + permission_classes = [IsAuthenticated] + required_query_params = ['name'] + + def parse_query_params(self, request): + output = {} + for query_param in self.required_query_params: + value = request.query_params.get(query_param) + if not value: + raise ParameterValidationFailed() + output[query_param] = value + return output + + def get(self, request, *args, **kwargs): + instance = self.find_object( + query_params=self.parse_query_params(request), + *args, + **kwargs) + if not instance: + raise NotFound() + serializer = self.serializer_class(instance) + return Response(serializer.data) diff --git a/metamapper/settings.py b/metamapper/settings.py index 77e9f31e..35d1d6eb 100644 --- a/metamapper/settings.py +++ b/metamapper/settings.py @@ -395,12 +395,22 @@ def envtobool(name, default): # CACHEOPS_REDIS = os.getenv('METAMAPPER_CACHEOPS_REDIS_URL') +API_THROTTLE_BACKEND = os.getenv( + 'METAMAPPER_API_THROTTLE_BACKEND', + 'django.core.cache.backends.dummy.DummyCache', +) + +API_THROTTLE_LOCATION = os.getenv('METAMAPPER_API_THROTTLE_LOCATION') CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.db.DatabaseCache', 'LOCATION': 'django_db_cache', - } + }, + 'api_throttle': { + 'BACKEND': API_THROTTLE_BACKEND, + 'LOCATION': API_THROTTLE_LOCATION, + }, } # If the CACHEOPS_REDIS variable isn't set, we assume you don't want