Skip to content

Commit e8adca0

Browse files
author
Dmitry Berezovsky
committed
Initial version
1 parent 8d66432 commit e8adca0

31 files changed

+611
-2
lines changed

.gitignore

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
.idea
12
# Byte-compiled / optimized / DLL files
23
__pycache__/
34
*.py[cod]
@@ -8,7 +9,7 @@ __pycache__/
89

910
# Distribution / packaging
1011
.Python
11-
env/
12+
/env/
1213
build/
1314
develop-eggs/
1415
dist/
@@ -85,7 +86,6 @@ celerybeat-schedule
8586
# virtualenv
8687
.venv
8788
venv/
88-
ENV/
8989

9090
# Spyder project settings
9191
.spyderproject

api_commons/__init__.py

Whitespace-only changes.

api_commons/common.py

+206
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import logging
2+
import sys
3+
from json import JSONEncoder
4+
from uuid import UUID
5+
6+
import collections
7+
from django.core.exceptions import ObjectDoesNotExist
8+
from django.http import HttpRequest
9+
from django.http import HttpResponse
10+
from rest_framework import status as HttpStatus
11+
from rest_framework.authentication import SessionAuthentication, BasicAuthentication
12+
from rest_framework.exceptions import NotAuthenticated, AuthenticationFailed
13+
from rest_framework.renderers import JSONRenderer
14+
from rest_framework.response import Response
15+
from rest_framework.settings import api_settings
16+
from rest_framework.views import APIView
17+
from typing import Union
18+
19+
from .dto import BaseDto, ApiResponseDto
20+
from .error import InvalidPaginationOptionsError, InvalidInputDataError, ErrorCode
21+
22+
DtoOrMessageOrErrorCode = Union[BaseDto, str, ErrorCode]
23+
Payload = Union[collections.Iterable, BaseDto, None]
24+
25+
logger = logging.getLogger(__name__)
26+
27+
28+
class ApiResponse(Response):
29+
__SECRET = object()
30+
31+
def __init__(self, payload: Payload = None, status=None, template_name=None, headers=None, exception=False,
32+
content_type='application/json',
33+
secret=None) -> None:
34+
if secret != self.__SECRET:
35+
raise ValueError(
36+
"Using constructor of the ApiResponse is not allowed, please "
37+
"use static methods instead. See .success()")
38+
39+
assert payload is None \
40+
or isinstance(payload, collections.Iterable) \
41+
or isinstance(payload, BaseDto), "payload should be subclass of ApiResponse"
42+
super().__init__(None, status, template_name, headers, exception, content_type)
43+
api_response_dto = ApiResponseDto(payload)
44+
self.data = api_response_dto
45+
46+
@classmethod
47+
def __update_response_for_error(cls, response, dto_or_error_message: DtoOrMessageOrErrorCode, error_code,
48+
default_message):
49+
if isinstance(dto_or_error_message, ErrorCode):
50+
error_code = dto_or_error_message.error_code
51+
dto_or_error_message = dto_or_error_message.dto_or_error_message
52+
if isinstance(dto_or_error_message, BaseDto):
53+
response.data.service.validation_errors = dto_or_error_message.errors
54+
response.data.service.error_code = error_code if error_code is not None else response.status_code
55+
response.data.service.error_message = default_message \
56+
if isinstance(dto_or_error_message, BaseDto) else str(dto_or_error_message)
57+
58+
@classmethod
59+
def not_found(cls, dto_or_error_message: DtoOrMessageOrErrorCode, error_code=None):
60+
response = ApiResponse(None, status=HttpStatus.HTTP_404_NOT_FOUND, secret=cls.__SECRET)
61+
cls.__update_response_for_error(response, dto_or_error_message, error_code, "Entity not found")
62+
return response
63+
64+
@classmethod
65+
def success(cls, payload: [BaseDto, None] = None, status: int = HttpStatus.HTTP_200_OK):
66+
return ApiResponse(payload, status=status, secret=cls.__SECRET)
67+
68+
@classmethod
69+
def not_authenticated(cls, *args):
70+
response = ApiResponse(None, status=HttpStatus.HTTP_401_UNAUTHORIZED, secret=cls.__SECRET)
71+
if len(args) > 0:
72+
message = args[0]
73+
else:
74+
message = "Unauthorized"
75+
response.data.service.error_message = message
76+
return response
77+
78+
@classmethod
79+
def bad_request(cls, dto_or_error_message: Union[BaseDto, str], error_code: int = None):
80+
response = ApiResponse(None, status=HttpStatus.HTTP_400_BAD_REQUEST, secret=cls.__SECRET)
81+
cls.__update_response_for_error(response, dto_or_error_message, error_code,
82+
"Bad request. Check service.validation_errors for details")
83+
return response
84+
85+
@classmethod
86+
def internal_server_error(cls, exception: Exception = None):
87+
response = ApiResponse(None, status=HttpStatus.HTTP_500_INTERNAL_SERVER_ERROR, secret=cls.__SECRET)
88+
response.data.service.error_code = HttpStatus.HTTP_500_INTERNAL_SERVER_ERROR
89+
return response
90+
91+
92+
class JsonEncoder(JSONEncoder):
93+
def default(self, o):
94+
if isinstance(o, BaseDto):
95+
return o.to_dict()
96+
if isinstance(o, UUID):
97+
return str(o)
98+
return super().default(o)
99+
100+
101+
class CsrfExemptSessionAuthentication(SessionAuthentication):
102+
def enforce_csrf(self, request: HttpRequest):
103+
pass
104+
105+
106+
class JsonRenderer(JSONRenderer):
107+
encoder_class = JsonEncoder
108+
media_type = 'application/json'
109+
format = 'json'
110+
ensure_ascii = not api_settings.UNICODE_JSON
111+
compact = api_settings.COMPACT_JSON
112+
113+
def get_indent(self, accepted_media_type, renderer_context: dict):
114+
return None if self.compact else 4
115+
116+
def render(self, api_response: ApiResponseDto, accepted_media_type=None, renderer_context=None):
117+
assert isinstance(api_response, ApiResponseDto), "api_response should be an instance of ApiResponseDto"
118+
return super().render(api_response, accepted_media_type, renderer_context)
119+
120+
121+
class BaseController(APIView):
122+
renderer_classes = [JsonRenderer]
123+
authentication_classes = (CsrfExemptSessionAuthentication,)
124+
125+
def __init__(self, **kwargs):
126+
super().__init__(**kwargs)
127+
128+
def parse_int(self, str_value, field_name=None) -> int:
129+
try:
130+
return int(str_value)
131+
except ValueError:
132+
raise InvalidInputDataError('Invalid {} value. Should be integer.'.format(field_name), str_value)
133+
134+
def parse_int_pk(self, str_value, field_name=None) -> int:
135+
pk = self.parse_int(str_value)
136+
if pk <= 0:
137+
raise InvalidInputDataError('Invalid {} value. Should be positive integer'.format(field_name), str_value)
138+
return pk
139+
140+
def parse_bool_value(self, str_value, field_name=None) -> bool:
141+
try:
142+
return str_value.lower() not in ['false', 'no', '1', 'False']
143+
except ValueError:
144+
raise InvalidInputDataError('Invalid {} value. Should be boolean'.format(field_name), str_value)
145+
146+
def get_bool_param_from_url(self, request, param):
147+
create_node = True
148+
if request.query_params.get(param) is not None:
149+
create_node = self.parse_bool_value(request.query_params[param], param)
150+
return create_node
151+
152+
def get_string_param_from_url(self, request, param):
153+
if request.query_params.get(param) is not None:
154+
return request.query_params[param]
155+
156+
157+
class BasicAuthController(BaseController):
158+
authentication_classes = (CsrfExemptSessionAuthentication, BasicAuthentication,)
159+
160+
def __init__(self, **kwargs):
161+
super().__init__(**kwargs)
162+
163+
164+
def exception_handler(exc: Exception, context):
165+
logger.warning("Incoming request processing was terminated due to handled exception:", exc_info=exc)
166+
if isinstance(exc, InvalidPaginationOptionsError):
167+
return ApiResponse.bad_request('Bad limit and/or offset')
168+
if isinstance(exc, (NotAuthenticated, AuthenticationFailed)):
169+
return ApiResponse.not_authenticated()
170+
if isinstance(exc, ObjectDoesNotExist):
171+
return ApiResponse.not_found(exc)
172+
if isinstance(exc, InvalidInputDataError):
173+
return ApiResponse.bad_request(str(exc))
174+
else:
175+
logger.exception(str(exc))
176+
return ApiResponse.internal_server_error(exc)
177+
raise exc
178+
179+
180+
def __set_response_attributes(response: HttpResponse):
181+
response.accepted_renderer = JsonRenderer()
182+
response.accepted_media_type = JsonRenderer.media_type
183+
response.renderer_context = {"data": None}
184+
return response.render()
185+
186+
187+
def error_500_handler(request: HttpRequest):
188+
exec_info = sys.exc_info()
189+
logger.error('Internal Server Error: %s.', str(exec_info[1]),
190+
exc_info=exec_info,
191+
extra={
192+
'status_code': 500,
193+
'rs.path': request.path,
194+
'request': request
195+
})
196+
return __set_response_attributes(ApiResponse.internal_server_error(exec_info[1]))
197+
198+
199+
def error_404_handler(request: HttpRequest, exception: Exception):
200+
logger.warning('Not Found: %s. %s', request.path, str(exception),
201+
extra={
202+
'status_code': 404,
203+
'path': request.path,
204+
'request': request
205+
})
206+
return __set_response_attributes(ApiResponse.not_found(""))

api_commons/dto.py

+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
from django.conf import settings
2+
from rest_framework.fields import empty, Field
3+
from rest_framework.serializers import Serializer
4+
5+
6+
class BaseDto(Serializer):
7+
def to_dict(self):
8+
if not hasattr(self, '_data'):
9+
return self.initial_data
10+
else:
11+
return self.data
12+
13+
def __init__(self, data=empty, **kwargs):
14+
self.initial_data = {}
15+
super(BaseDto, self).__init__(None, data, **kwargs)
16+
17+
def __setattr__(self, key, value):
18+
if key in self.get_declared_fields():
19+
self.initial_data[key] = value
20+
else:
21+
super().__setattr__(key, value)
22+
23+
def __getattr__(self, key):
24+
if key in self.get_declared_fields():
25+
return self.initial_data.get(key)
26+
else:
27+
if key in dir(super(BaseDto, self)):
28+
return getattr(super(), key)
29+
else:
30+
raise AttributeError("Object {} doesn't have attribute {}".format(self.__class__.__name__, key))
31+
32+
@classmethod
33+
def from_dict(cls, dictionary=empty):
34+
instance = cls(dictionary)
35+
return instance
36+
37+
@classmethod
38+
def get_declared_fields(cls):
39+
if hasattr(cls, '_declared_fields'):
40+
return getattr(cls, '_declared_fields')
41+
else:
42+
return []
43+
44+
45+
class ApiResponseServiceSection(BaseDto):
46+
def __init__(self):
47+
self.error_code = 0
48+
self.error_message = None
49+
self.validation_errors = []
50+
self.api_version = settings.API_VERSION
51+
52+
def is_successful(self):
53+
return self.error_code == 0
54+
55+
def to_dict(self):
56+
return {
57+
"error_code": self.error_code,
58+
"node_id": settings.HOSTNAME,
59+
"error_message": self.error_message,
60+
"validation_errors": self.validation_errors,
61+
"successful": self.is_successful(),
62+
"api_version": self.api_version
63+
}
64+
65+
66+
class ApiResponseDto(BaseDto):
67+
def __init__(self, payload=None):
68+
self.payload = payload
69+
self.service = ApiResponseServiceSection()
70+
71+
def to_dict(self):
72+
serialized_payload = self.payload
73+
if isinstance(self.payload, BaseDto):
74+
serialized_payload = self.payload.to_representation(self.payload)
75+
return {
76+
"payload": serialized_payload,
77+
"service": self.service.to_dict()
78+
}
79+
80+
81+
class RelatedDtoField(Field):
82+
def __init__(self, dto_class, required: bool = None, default=empty, initial=empty) -> None:
83+
super().__init__(read_only=False, write_only=False, source=None, required=required, default=default,
84+
initial=initial,
85+
label=None, help_text=None, style=None, error_messages=None, validators=None, allow_null=False)
86+
self.dto_class = dto_class
87+
88+
def to_representation(self, instance: BaseDto):
89+
return instance.to_dict()
90+
91+
def to_internal_value(self, data: dict):
92+
dto = self.dto_class.from_dict(data)
93+
dto.is_valid()
94+
return dto
95+
96+
97+
class PaginationOptions(object):
98+
""" Pagination options class, has offset and limit parameters."""
99+
100+
def __init__(self, offset: int = 0, limit: int = 50) -> None:
101+
""" Return pagination options object with limit and offset.
102+
:param offset: Pagination offset
103+
:type offset: int
104+
:param limit: Pagination limit
105+
:type limit: int
106+
:rtype: PaginationOptions
107+
"""
108+
self.offset = offset
109+
self.limit = limit

api_commons/error.py

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
class InvalidInputDataError(Exception):
2+
def __init__(self, msg, input_value, *args, **kwargs):
3+
super().__init__(msg, *args, **kwargs)
4+
self.input_value = input_value
5+
6+
7+
class InvalidPaginationOptionsError(Exception):
8+
pass
9+
10+
11+
class NotFoundException(Exception):
12+
def __init__(self, msg, input_value, *args, **kwargs):
13+
super().__init__(msg, *args, **kwargs)
14+
self.input_value = input_value
15+
16+
17+
class ErrorCode:
18+
def __init__(self, error_code, dto_or_error_message=''):
19+
self.error_code = error_code
20+
self.dto_or_error_message = dto_or_error_message

development/copyright-update

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/bin/bash
2+
3+
cd $(dirname "$(realpath "$0")")
4+
copyright -c ./copyright.json ../api_commons

development/copyright.json

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"author": "Dmitry Berezovsky",
3+
"back": false,
4+
"exclude": "",
5+
"include": "*.py",
6+
"license": "mit",
7+
"program": "Django API Commons",
8+
"short": "Boilerplate commons for django based web api application.",
9+
"single": true,
10+
"year": "2017"
11+
}

example_service/__init__.py

Whitespace-only changes.

example_service/api/__init__.py

Whitespace-only changes.

example_service/api/calculator/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)