base-api-utils 1.0.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. base_api_utils-1.0.6/LICENSE +21 -0
  2. base_api_utils-1.0.6/PKG-INFO +62 -0
  3. base_api_utils-1.0.6/README.md +44 -0
  4. base_api_utils-1.0.6/pyproject.toml +32 -0
  5. base_api_utils-1.0.6/setup.cfg +4 -0
  6. base_api_utils-1.0.6/src/base_api_utils/__init__.py +0 -0
  7. base_api_utils-1.0.6/src/base_api_utils/filters/__init__.py +1 -0
  8. base_api_utils-1.0.6/src/base_api_utils/filters/base_filter.py +115 -0
  9. base_api_utils-1.0.6/src/base_api_utils/security/__init__.py +3 -0
  10. base_api_utils-1.0.6/src/base_api_utils/security/abstract_access_token_service.py +11 -0
  11. base_api_utils-1.0.6/src/base_api_utils/security/abstract_oauth2_api_client.py +97 -0
  12. base_api_utils-1.0.6/src/base_api_utils/security/access_token_service.py +64 -0
  13. base_api_utils-1.0.6/src/base_api_utils/security/group_required.py +71 -0
  14. base_api_utils-1.0.6/src/base_api_utils/security/oauth2_authentication.py +35 -0
  15. base_api_utils-1.0.6/src/base_api_utils/security/oauth2_client_factory.py +42 -0
  16. base_api_utils-1.0.6/src/base_api_utils/security/oauth2_scope_required.py +75 -0
  17. base_api_utils-1.0.6/src/base_api_utils/security/shared.py +13 -0
  18. base_api_utils-1.0.6/src/base_api_utils/serializers/__init__.py +1 -0
  19. base_api_utils-1.0.6/src/base_api_utils/serializers/base_model_serializer.py +96 -0
  20. base_api_utils-1.0.6/src/base_api_utils/utils/__init__.py +5 -0
  21. base_api_utils-1.0.6/src/base_api_utils/utils/config.py +18 -0
  22. base_api_utils-1.0.6/src/base_api_utils/utils/exceptions.py +32 -0
  23. base_api_utils-1.0.6/src/base_api_utils/utils/file_lock.py +98 -0
  24. base_api_utils-1.0.6/src/base_api_utils/utils/pagination.py +43 -0
  25. base_api_utils-1.0.6/src/base_api_utils/utils/string.py +15 -0
  26. base_api_utils-1.0.6/src/base_api_utils.egg-info/PKG-INFO +62 -0
  27. base_api_utils-1.0.6/src/base_api_utils.egg-info/SOURCES.txt +28 -0
  28. base_api_utils-1.0.6/src/base_api_utils.egg-info/dependency_links.txt +1 -0
  29. base_api_utils-1.0.6/src/base_api_utils.egg-info/requires.txt +6 -0
  30. base_api_utils-1.0.6/src/base_api_utils.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 FNTECH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.2
2
+ Name: base-api-utils
3
+ Version: 1.0.6
4
+ Summary: Django Rest Framework micro services common utilities
5
+ Author-email: Román Gutierrez <roman@tipit.net>
6
+ Project-URL: Homepage, https://github.com/fntechgit/base-api-utils
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: djangorestframework>=3.15.2
13
+ Requires-Dist: requests-oauthlib
14
+ Requires-Dist: requests
15
+ Requires-Dist: oauthlib
16
+ Requires-Dist: django_filters
17
+ Requires-Dist: django
18
+
19
+ # base-api-utils
20
+ DRF common utilities
21
+
22
+ ## Virtual Env
23
+
24
+ ````bash
25
+ $ python3 -m venv env
26
+
27
+ $ source env/bin/activate
28
+ ````
29
+
30
+ ## python setup
31
+
32
+ ````bash
33
+ sudo add-apt-repository ppa:deadsnakes/ppa -y
34
+ sudo apt-get -y -f install python3.7 python3-pip python3.7-dev python3.7-venv libpython3.7-dev python3-setuptools
35
+ sudo -H pip3 --default-timeout=50 install --upgrade pip
36
+ sudo -H pip3 install virtualenv
37
+ ````
38
+
39
+ ## Install reqs
40
+
41
+ ````
42
+ pip install -r requirements.txt
43
+ ````
44
+
45
+ ## Packaging
46
+
47
+ python3 -m pip install --upgrade build
48
+ python3 -m build
49
+
50
+ ## Uploading ( Test Py Pi)
51
+
52
+ python3 -m pip install --upgrade twine
53
+ python3 -m twine upload --repository testpypi dist/*
54
+
55
+ ## Publish to PyPI
56
+ twine upload dist/*
57
+
58
+ ## Install locally
59
+ pip install -i https://test.pypi.org/simple/ base-api-utils --no-deps
60
+
61
+ ## Install from GitHub
62
+ pip install git+https://github.com/fntechgit/base-api-utils
@@ -0,0 +1,44 @@
1
+ # base-api-utils
2
+ DRF common utilities
3
+
4
+ ## Virtual Env
5
+
6
+ ````bash
7
+ $ python3 -m venv env
8
+
9
+ $ source env/bin/activate
10
+ ````
11
+
12
+ ## python setup
13
+
14
+ ````bash
15
+ sudo add-apt-repository ppa:deadsnakes/ppa -y
16
+ sudo apt-get -y -f install python3.7 python3-pip python3.7-dev python3.7-venv libpython3.7-dev python3-setuptools
17
+ sudo -H pip3 --default-timeout=50 install --upgrade pip
18
+ sudo -H pip3 install virtualenv
19
+ ````
20
+
21
+ ## Install reqs
22
+
23
+ ````
24
+ pip install -r requirements.txt
25
+ ````
26
+
27
+ ## Packaging
28
+
29
+ python3 -m pip install --upgrade build
30
+ python3 -m build
31
+
32
+ ## Uploading ( Test Py Pi)
33
+
34
+ python3 -m pip install --upgrade twine
35
+ python3 -m twine upload --repository testpypi dist/*
36
+
37
+ ## Publish to PyPI
38
+ twine upload dist/*
39
+
40
+ ## Install locally
41
+ pip install -i https://test.pypi.org/simple/ base-api-utils --no-deps
42
+
43
+ ## Install from GitHub
44
+ pip install git+https://github.com/fntechgit/base-api-utils
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [tool.setuptools.packages.find]
6
+ where = ["src"]
7
+
8
+ [project]
9
+ name = "base-api-utils"
10
+ version = "1.0.6"
11
+ dependencies = [
12
+ "djangorestframework>=3.15.2",
13
+ "requests-oauthlib",
14
+ "requests",
15
+ "oauthlib",
16
+ "django_filters",
17
+ "django"
18
+ ]
19
+ authors = [
20
+ { name="Román Gutierrez", email="roman@tipit.net" },
21
+ ]
22
+ description = "Django Rest Framework micro services common utilities"
23
+ readme = "README.md"
24
+ requires-python = ">=3.10"
25
+ classifiers = [
26
+ "Programming Language :: Python :: 3",
27
+ "Operating System :: OS Independent",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/fntechgit/base-api-utils"
32
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1 @@
1
+ from .base_filter import BaseFilter
@@ -0,0 +1,115 @@
1
+ import re
2
+
3
+ from django.db.models import Q
4
+ from django_filters import rest_framework as filters
5
+ from rest_framework.exceptions import ValidationError
6
+
7
+ from utils import safe_str_to_number
8
+
9
+ class BaseFilter(filters.FilterSet):
10
+ filter_op = '='
11
+ filters_separator = ','
12
+ name_operator_separator = '__'
13
+ filter_param = 'filter'
14
+ filters_param = 'filter[]'
15
+
16
+ operator_map = {
17
+ '>=': "__gte",
18
+ '<=': "__lte",
19
+ '>': "__gt",
20
+ '<': "__lt",
21
+ '==': "",
22
+ '=@': "__icontains",
23
+ '@@': "__istartswith"
24
+ }
25
+
26
+ def __init__(self, data=None, queryset=None, *, request=None, prefix=None):
27
+ super().__init__(data=data, queryset=queryset, request=request, prefix=prefix)
28
+ self.split_pattern = '|'.join(map(re.escape, self.operator_map.keys()))
29
+ self.is_number_re = re.compile(r'^-?\d+(\.\d+)?([eE][-+]?\d+)?$')
30
+
31
+ def is_numeric(self, value):
32
+ return bool(self.is_number_re.match(value))
33
+
34
+ def parse_filter(self, filter_str):
35
+ try:
36
+ parts = re.split(f"({self.split_pattern})", filter_str)
37
+ field_name = parts[0]
38
+ operator = self.operator_map.get(parts[1].strip())
39
+ value = parts[2]
40
+ except ValueError:
41
+ raise ValidationError("Invalid filter format. Expected 'key=value'.")
42
+ return field_name.strip(), operator, value.strip()
43
+
44
+ def filter_field(self, queryset, filter_name, filter_op, filter_value):
45
+ """
46
+ Generic method for filtering based on dynamic comparisons.
47
+ """
48
+ if not filter_op is None:
49
+ value_to_filter = (
50
+ safe_str_to_number(filter_value)) \
51
+ if filter_op in ['__gte', '__lte', '__gt', '__lt', ''] and self.is_numeric(filter_value) \
52
+ else filter_value
53
+
54
+ return queryset.filter(**{f"{filter_name}{filter_op}": value_to_filter})
55
+
56
+ return queryset
57
+
58
+ def apply_or_filters(self, queryset, or_filters):
59
+ or_filter_subquery = Q()
60
+ for or_filter in or_filters:
61
+ filter_name, filter_op, filter_value = self.parse_filter(or_filter)
62
+ or_filter_subquery |= Q(**{f"{filter_name}{filter_op}": filter_value})
63
+ queryset = queryset.filter(or_filter_subquery)
64
+ return queryset
65
+
66
+ def filter_queryset(self, queryset):
67
+ """
68
+ Overrides the `filter_queryset` method to apply filters dynamically.
69
+ Invoked when Django applies filters to the query.
70
+
71
+ Examples of possible filters:
72
+ - filter[]=field1=@value1,field2>value2 (OR filter)
73
+
74
+ - filter=field1=@value1,field2>value2 (OR filter)
75
+
76
+ - filter[]=field1=@value1&filter[]=field2>value2 (AND filter)
77
+
78
+ - filter==field1@@value
79
+
80
+ - filter[]=field1=@value1,field2=@value2&filter[]=field3=@value3 (OR then AND filter)
81
+ Generates: ...WHERE (field1 LIKE '%value1%' OR field2 LIKE '%value2%') AND field3 LIKE '%value3%'
82
+ """
83
+ and_filter_key = 'and'
84
+ or_filter_key = 'or'
85
+
86
+ filter_without_brackets = self.data.get(self.filter_param, None)
87
+ filter_with_brackets = self.data.getlist(self.filters_param, [])
88
+
89
+ filter_category = {
90
+ or_filter_key: filter_without_brackets.split(self.filters_separator) if filter_without_brackets else [],
91
+ and_filter_key: []
92
+ }
93
+
94
+ for item in filter_with_brackets:
95
+ if "," in item:
96
+ filter_category[or_filter_key].extend(item.split(self.filters_separator))
97
+ else:
98
+ filter_category[and_filter_key].append(item)
99
+
100
+ # Apply dynamic filters
101
+ if filter_category[or_filter_key]:
102
+ queryset = self.apply_or_filters(queryset, filter_category[or_filter_key])
103
+
104
+ if not filter_category[and_filter_key]:
105
+ return queryset
106
+
107
+ # Get filters defined in the FilterSet
108
+ active_filters = self.get_filters()
109
+
110
+ for and_filter in filter_category[and_filter_key]:
111
+ filter_name, filter_op, filter_value = self.parse_filter(and_filter)
112
+ if filter_name in active_filters:
113
+ queryset = self.filter_field(queryset, filter_name, filter_op, filter_value)
114
+
115
+ return queryset
@@ -0,0 +1,3 @@
1
+ from .oauth2_authentication import OAuth2Authentication
2
+ from .oauth2_scope_required import oauth2_scope_required
3
+ from .group_required import group_required
@@ -0,0 +1,11 @@
1
+ from abc import abstractmethod
2
+
3
+
4
+ class AbstractAccessTokenService:
5
+
6
+ def __init__(self):
7
+ pass
8
+
9
+ @abstractmethod
10
+ def validate(self, access_token:str):
11
+ pass
@@ -0,0 +1,97 @@
1
+ import logging
2
+ from abc import abstractmethod
3
+
4
+ from django.core.cache import cache
5
+ from requests_oauthlib import OAuth2Session
6
+ from requests.exceptions import RequestException
7
+ from rest_framework.status import HTTP_404_NOT_FOUND, HTTP_401_UNAUTHORIZED
8
+
9
+ from security.oauth2_client_factory import OAuth2ClientFactory
10
+ from utils import config
11
+
12
+
13
+ class AbstractOAuth2APIClient:
14
+ MAX_RETRIES = 3
15
+ SKEW_TIME = 120
16
+ ACCESS_TOKEN_CACHE_KEY_TEMPLATE = "{}_OAUTH2_ACCESS_TOKEN"
17
+
18
+ @abstractmethod
19
+ def get_app_name(self) -> str:
20
+ pass
21
+
22
+ def get_idp_config(self) -> dict:
23
+ return {
24
+ "authorization_endpoint": f'{config('OAUTH2.IDP.BASE_URL')}/{config('OAUTH2.IDP.AUTHORIZATION_ENDPOINT')}',
25
+ "token_endpoint": f'{config('OAUTH2.IDP.BASE_URL')}/{config('OAUTH2.IDP.TOKEN_ENDPOINT')}',
26
+ }
27
+
28
+ @abstractmethod
29
+ def get_app_config(self) -> dict:
30
+ pass
31
+
32
+ def get_idp_client(self) -> OAuth2Session:
33
+ return OAuth2ClientFactory.build(
34
+ self.get_idp_config(),
35
+ self.get_app_config()
36
+ )
37
+
38
+ def get_access_token_cache_key(self) -> str:
39
+ return self.ACCESS_TOKEN_CACHE_KEY_TEMPLATE.format(self.get_app_name())
40
+
41
+ def invoke_with_retry(self, callback):
42
+ retries = 0
43
+ while retries < self.MAX_RETRIES:
44
+ try:
45
+ return callback(self)
46
+ except RequestException as ex:
47
+ logging.warning("Retrying due to RequestException...")
48
+ retries += 1
49
+ if retries >= self.MAX_RETRIES:
50
+ if ex.response and ex.response.status_code == HTTP_404_NOT_FOUND:
51
+ return None
52
+ raise
53
+ self.clean_access_token()
54
+ logging.warning(f"Retry attempt {retries}: {ex}")
55
+ except Exception as ex:
56
+ self.clean_access_token()
57
+ logging.error(f"Unhandled exception: {ex}")
58
+ raise
59
+
60
+ def get_access_token(self) -> str:
61
+ logging.debug("AbstractOAuth2Api::get_access_token")
62
+ cache_key = self.get_access_token_cache_key()
63
+ token = cache.get(cache_key)
64
+
65
+ if not token:
66
+ try:
67
+ logging.debug("AbstractOAuth2Api::get_access_token - Access token not found in cache, requesting new token...")
68
+ client = self.get_idp_client()
69
+ app_config = self.get_app_config()
70
+ client_secret = app_config.get('client_secret', '')
71
+
72
+ token_response = client.fetch_token(
73
+ token_url=f'{config('OAUTH2.IDP.BASE_URL')}/{config('OAUTH2.IDP.TOKEN_ENDPOINT')}',
74
+ client_secret=client_secret
75
+ )
76
+
77
+ token = token_response["access_token"]
78
+ expires_in = token_response.get("expires_in", 3600)
79
+ ttl = max(0, expires_in - self.SKEW_TIME)
80
+
81
+ if ttl > 0:
82
+ cache.set(cache_key, token, ttl)
83
+
84
+ logging.debug(f"AbstractOAuth2Api::get_access_token - New access token obtained: {token}, expires in {ttl} seconds.")
85
+ except RequestException as ex:
86
+ logging.warning(f"RequestException: {ex}")
87
+ if ex.response and ex.response.status_code == HTTP_401_UNAUTHORIZED:
88
+ cache.delete(cache_key)
89
+ raise
90
+
91
+ logging.debug(f"AbstractOAuth2Api::Returning cached access token: {token}")
92
+ return token
93
+
94
+ def clean_access_token(self):
95
+ cache_key = self.get_access_token_cache_key()
96
+ cache.delete(cache_key)
97
+
@@ -0,0 +1,64 @@
1
+ import logging
2
+ import sys
3
+
4
+ import requests
5
+ from django.contrib.auth.models import AnonymousUser
6
+ from django.core.cache import cache
7
+
8
+ from .abstract_access_token_service import AbstractAccessTokenService
9
+ from utils import config
10
+
11
+
12
+ class AccessTokenService(AbstractAccessTokenService):
13
+
14
+ def validate(self, access_token:str):
15
+ """
16
+ Authenticate the request, given the access token.
17
+ """
18
+ logging.getLogger('oauth2').debug('AccessTokenService::validate trying to get {access_token} from cache ...'.format(access_token=access_token))
19
+ # try get access_token from DB and check if not expired
20
+ cached_token_info = cache.get(access_token)
21
+
22
+ if cached_token_info is None:
23
+ try:
24
+ logging.getLogger('oauth2').debug(
25
+ 'AccessTokenService::validate {access_token} is not present on cache, trying to validate from instrospection endpoint'.format(access_token=access_token))
26
+ response = requests.post(
27
+ '{base_url}/{endpoint}'.format
28
+ (
29
+ base_url=config('OAUTH2.IDP.BASE_URL', None),
30
+ endpoint=config('OAUTH2.IDP.INTROSPECTION_ENDPOINT', None)
31
+ ),
32
+ auth=(config('OAUTH2.CLIENT.ID', None), config('OAUTH2.CLIENT.SECRET', None),),
33
+ params={'token': access_token},
34
+ verify=False if config('DEBUG', False) else True,
35
+ allow_redirects=False
36
+ )
37
+
38
+ if response.status_code == requests.codes.ok:
39
+ cached_token_info = response.json()
40
+ lifetime = config('OAUTH2.CLIENT.ACCESS_TOKEN_CACHE_LIFETIME', cached_token_info['expires_in'])
41
+ logging.getLogger('oauth2').debug(
42
+ 'AccessTokenService::validate {access_token} storing on cache with lifetime {lifetime}'.format(
43
+ access_token=access_token, lifetime=lifetime))
44
+ cache.set(access_token, cached_token_info, timeout=int(lifetime))
45
+ logging.getLogger('oauth2').warning(
46
+ 'http code {code} http content {content}'.format(code=response.status_code,
47
+ content=response.content))
48
+ return AnonymousUser, cached_token_info
49
+
50
+ logging.getLogger('oauth2').warning(
51
+ 'AccessTokenService::validate http code {code} http content {content}'.format(code=response.status_code,
52
+ content=response.content))
53
+ return None
54
+ except requests.exceptions.RequestException as e:
55
+ logging.getLogger('oauth2').error(e)
56
+ return None
57
+ except:
58
+ logging.getLogger('oauth2').error(sys.exc_info())
59
+ return None
60
+
61
+ logging.getLogger('oauth2').debug(
62
+ 'AccessTokenService::validate {access_token} cache hit'.format(
63
+ access_token=access_token))
64
+ return AnonymousUser, cached_token_info
@@ -0,0 +1,71 @@
1
+ import logging
2
+ import os
3
+ import json
4
+
5
+ from django.core.exceptions import PermissionDenied
6
+ from django.utils.functional import wraps
7
+ from django.utils.translation import gettext_lazy as _
8
+
9
+ from security.shared import get_path
10
+ from utils import config, is_empty
11
+
12
+
13
+ def group_required():
14
+ """
15
+ Decorator to make a view only accept particular groups:
16
+ """
17
+ def decorator(func):
18
+ @wraps(func)
19
+ def inner(view, *args, **kwargs):
20
+
21
+ request = view.request
22
+ token_info = request.auth
23
+
24
+ # shortcircuit
25
+ if os.getenv("ENV") == 'test':
26
+ return func(view, *args, **kwargs)
27
+
28
+ logger = logging.getLogger('oauth2')
29
+
30
+ path = get_path(request)
31
+ method = str(request.method).lower()
32
+
33
+ logger.debug(f'endpoint {method} {path}')
34
+
35
+ endpoints = config('OAUTH2.CLIENT.ENDPOINTS')
36
+
37
+ if token_info is None:
38
+ logger.warning('group_required::decorator token info not present')
39
+ raise PermissionDenied(_("token info not present."))
40
+
41
+ logger.debug(f'group_required::decorator token_info {json.dumps(token_info)}')
42
+
43
+ endpoint = endpoints[path] if path in endpoints else None
44
+ if not endpoint:
45
+ logger.warning('group_required::decorator endpoint info not present')
46
+ raise PermissionDenied(_("endpoint info not present."))
47
+
48
+ endpoint = endpoint[method] if method in endpoint else None
49
+ if not endpoint:
50
+ logger.warning('endpoint info not present')
51
+ raise PermissionDenied(_("endpoint info not present."))
52
+
53
+ required_groups = endpoint['groups'] if 'groups' in endpoint else None
54
+
55
+ if is_empty(required_groups):
56
+ return func(view, *args, **kwargs)
57
+
58
+ if 'user_groups' in token_info:
59
+ current_groups = token_info['user_groups']
60
+ logger.debug(f'current group {current_groups} required groups {required_groups}')
61
+
62
+ req_groups = set(required_groups.split())
63
+ user_groups = set([item['slug'] for item in current_groups])
64
+
65
+ if any(group in req_groups for group in user_groups):
66
+ return func(view, *args, **kwargs)
67
+
68
+ logger.warning('group_required::decorator token groups not present')
69
+ raise PermissionDenied(_("token groups not present"))
70
+ return inner
71
+ return decorator
@@ -0,0 +1,35 @@
1
+ import logging
2
+
3
+ from rest_framework import exceptions
4
+ from rest_framework.authentication import get_authorization_header, BaseAuthentication
5
+
6
+ from security.access_token_service import AccessTokenService
7
+
8
+
9
+ class OAuth2Authentication(BaseAuthentication):
10
+
11
+ def __init__(self):
12
+ self.service = AccessTokenService()
13
+
14
+ def authenticate(self, request):
15
+
16
+ auth = get_authorization_header(request).split()
17
+
18
+ if len(auth) == 1:
19
+ msg = 'Invalid bearer header. No credentials provided.'
20
+ raise exceptions.AuthenticationFailed(msg)
21
+ elif len(auth) > 2:
22
+ msg = 'Invalid bearer header. Token string should not contain spaces.'
23
+ raise exceptions.AuthenticationFailed(msg)
24
+
25
+ if auth and auth[0].lower() == b'bearer':
26
+ access_token = auth[1]
27
+ elif 'access_token' in request.POST:
28
+ access_token = request.POST['access_token']
29
+ elif 'access_token' in request.GET:
30
+ access_token = request.GET['access_token']
31
+ else:
32
+ return None
33
+ logging.getLogger('oauth2').warning(
34
+ 'OAuth2Authentication::authenticate access_token {access_token}'.format(access_token=access_token))
35
+ return self.service.validate(access_token)
@@ -0,0 +1,42 @@
1
+ import logging
2
+
3
+ from oauthlib.oauth2 import BackendApplicationClient
4
+ from requests_oauthlib import OAuth2Session
5
+ from requests.adapters import HTTPAdapter
6
+ from urllib3 import Retry
7
+
8
+
9
+ class OAuth2ClientFactory:
10
+ @staticmethod
11
+ def build(idp_config: dict, app_config: dict) -> OAuth2Session:
12
+ client_id = app_config.get('client_id', '')
13
+ client_secret = app_config.get('client_secret', '')
14
+ scopes = app_config.get('scopes', '').split()
15
+
16
+ client = BackendApplicationClient(client_id=client_id, scope=scopes)
17
+
18
+ # OAuth2 endpoint urls
19
+ authorization_endpoint = idp_config.get('authorization_endpoint', '')
20
+ token_endpoint = idp_config.get('token_endpoint', '')
21
+
22
+ session = OAuth2Session(client=client)
23
+
24
+ # Retries policy
25
+ retries = Retry(
26
+ total=3,
27
+ backoff_factor=1,
28
+ status_forcelist=[429, 500, 502, 503, 504]
29
+ )
30
+
31
+ # Retries HTTP adapter
32
+ http_adapter = HTTPAdapter(max_retries=retries)
33
+ session.mount('https://', http_adapter)
34
+ session.mount('http://', http_adapter)
35
+
36
+ # Auth and endpoints settings
37
+ session.auth = (client_id, client_secret)
38
+ session.authorization_url = authorization_endpoint
39
+ session.token_url = token_endpoint
40
+
41
+ logging.info("OAuth2ClientFactory - OAuth2Session created successfully with client_id: %s", client_id)
42
+ return session
@@ -0,0 +1,75 @@
1
+ import logging
2
+ import os
3
+ import json
4
+
5
+ from django.core.exceptions import PermissionDenied
6
+ from django.utils.functional import wraps
7
+ from django.utils.translation import gettext_lazy as _
8
+
9
+ from security.shared import get_path
10
+ from utils import config, is_empty
11
+
12
+
13
+ def oauth2_scope_required():
14
+ """
15
+ Decorator to make a view only accept particular scopes:
16
+ """
17
+ def decorator(func):
18
+ @wraps(func)
19
+ def inner(view, *args, **kwargs):
20
+
21
+ request = view.request
22
+ token_info = request.auth
23
+
24
+ # shortcircuit
25
+ if os.getenv("ENV") == 'test':
26
+ return func(view, token_info = {}, *args, **kwargs)
27
+
28
+ logger = logging.getLogger('oauth2')
29
+
30
+ path = get_path(request)
31
+ method = str(request.method).lower()
32
+
33
+ logger.debug('endpoint {method} {path}'.format(method=method, path=path))
34
+
35
+ endpoints = config('OAUTH2.CLIENT.ENDPOINTS')
36
+
37
+ if token_info is None:
38
+ logging.getLogger('oauth2').warning(
39
+ 'oauth2_scope_required::decorator token info not present')
40
+ raise PermissionDenied(_("token info not present."))
41
+
42
+ logger.debug('oauth2_scope_required::decorator token_info {}'.format(
43
+ json.dumps(token_info)
44
+ ))
45
+
46
+ endpoint = endpoints[path] if path in endpoints else None
47
+ if not endpoint:
48
+ logger.warning('oauth2_scope_required::decorator endpoint info not present')
49
+ raise PermissionDenied(_("endpoint info not present."))
50
+
51
+ endpoint = endpoint[method] if method in endpoint else None
52
+ if not endpoint:
53
+ logger.warning('endpoint info not present')
54
+ raise PermissionDenied(_("endpoint info not present."))
55
+
56
+ required_scope = endpoint['scopes'] if 'scopes' in endpoint else None
57
+
58
+ if is_empty(required_scope):
59
+ logger.warning('require scope is empty')
60
+ raise PermissionDenied(_("required scope not present."))
61
+
62
+ if 'scope' in token_info:
63
+ current_scope = token_info['scope']
64
+
65
+ logger.debug(f'current scope {current_scope} required scope {required_scope}')
66
+ # check origins
67
+ # check scopes
68
+
69
+ if len(set.intersection(set(required_scope.split()), set(current_scope.split()))):
70
+ return func(view, token_info=token_info, *args, **kwargs)
71
+
72
+ logger.warning('oauth2_scope_required::decorator token scopes not present')
73
+ raise PermissionDenied(_("token scopes not present"))
74
+ return inner
75
+ return decorator
@@ -0,0 +1,13 @@
1
+ import re
2
+
3
+ from django.contrib.admindocs.views import simplify_regex
4
+
5
+ _PATH_PARAMETER_COMPONENT_RE = re.compile(
6
+ r'<(?:(?P<converter>[^>:]+):)?(?P<parameter>\w+)>'
7
+ )
8
+
9
+ def get_path(request) -> str:
10
+ path = simplify_regex(request.resolver_match.route)
11
+ # Strip Django 2.0 convertors as they are incompatible with uritemplate format
12
+ path = re.sub(_PATH_PARAMETER_COMPONENT_RE, r'{\g<parameter>}', path)
13
+ return path.replace('{pk}', '{id}')
@@ -0,0 +1 @@
1
+ from .base_model_serializer import BaseModelSerializer
@@ -0,0 +1,96 @@
1
+ from rest_framework import serializers
2
+
3
+
4
+ class BaseModelSerializer(serializers.ModelSerializer):
5
+ PARENT_FIELD_KEY = 'parent_field'
6
+
7
+ def __init__(self, *args, **kwargs):
8
+ context = kwargs.get('context', {})
9
+ request = context.get('request', None)
10
+ fields = []
11
+ relations = []
12
+
13
+ if request:
14
+ fields = request.query_params.get('fields', '').split(',')
15
+ relations = request.query_params.get('relations', '').split(',')
16
+
17
+ kwargs.pop('expand', None)
18
+ kwargs.pop('fields', None)
19
+ kwargs.pop('relations', None)
20
+ kwargs.pop('params', None)
21
+
22
+ super().__init__(*args, **kwargs)
23
+
24
+ fields_requested = set(fields) if fields else set()
25
+ relations_requested = set(relations) if relations else set()
26
+
27
+ allowed_fields = set(self.get_allowed_fields())
28
+ allowed_relations = set(self.get_allowed_relations())
29
+ fields_requested &= allowed_fields
30
+ relations_requested &= allowed_relations
31
+
32
+ if fields_requested or relations_requested:
33
+ # due to how the DRF serialization mechanism works, both fields and relations are treated the same
34
+ allowed = fields_requested.union(relations_requested)
35
+
36
+ # gets the last key of each requested field (dot notation) to search in the list of fields in the current serializer
37
+ allowed = {item.split('.')[-1] for item in allowed}
38
+
39
+ for field_name in list(self.fields):
40
+ if field_name not in allowed:
41
+ # if the field wasn't specified in request it is removed from the list of fields to serialize
42
+ self.fields.pop(field_name)
43
+
44
+ def get_allowed_fields(self):
45
+ # required to know if the serializer was called explicitly from the view or
46
+ # if it is a delegation from a parent serializer (parent collection field)
47
+ # - view explicit call -> field_name
48
+ # - parent serializer delegation -> parent_field_name.field_name
49
+ parent_field = self.context.get(self.PARENT_FIELD_KEY, None)
50
+
51
+ if parent_field:
52
+ return [f"{parent_field}.{field}" for field in self.fields.keys()]
53
+
54
+ return self.fields.keys()
55
+
56
+ def get_delegated_allowed_relations(self, allowed_relations):
57
+ # required to know if the serializer was called explicitly from the view or
58
+ # if it is a delegation from a parent serializer (parent collection field)
59
+ # - view explicit call -> field_name
60
+ # - parent serializer delegation -> parent_field_name.field_name
61
+ parent_field = self.context.get(self.PARENT_FIELD_KEY, None)
62
+
63
+ if parent_field:
64
+ return [f"{parent_field}.{allowed_relation}" for allowed_relation in allowed_relations]
65
+
66
+ return allowed_relations
67
+
68
+ def get_allowed_relations(self):
69
+ return []
70
+
71
+ def get_expand(self):
72
+ request = self.context.get('request', None)
73
+ str_expand = request.query_params.get('expand', '') if request else None
74
+ if not str_expand:
75
+ return []
76
+
77
+ expand = str_expand.split(',')
78
+ parent_field = self.context.get(self.PARENT_FIELD_KEY, None)
79
+
80
+ if parent_field:
81
+ # current serializer related expand
82
+ pattern = f'{parent_field}.'
83
+ return [
84
+ field.split(pattern)[1]
85
+ for field in expand
86
+ if pattern in field
87
+ ]
88
+
89
+ return expand
90
+
91
+ def get_validation_detail(self, code = 0):
92
+ errors = [f'{field}: {str(detail)}'
93
+ for field, details in self.errors.items()
94
+ for detail in details
95
+ ]
96
+ return {'message': 'Validation Failed', 'errors': errors, 'code': code}
@@ -0,0 +1,5 @@
1
+ from .pagination import LargeResultsSetPagination
2
+ from .config import config
3
+ from .string import is_empty, safe_str_to_number
4
+ from .file_lock import FileLock
5
+ from .exceptions import S3Exception
@@ -0,0 +1,18 @@
1
+ from django.conf import settings
2
+
3
+
4
+ def config(name: str, default=None):
5
+ """
6
+ Helper function to get a Django setting by name. If setting doesn't exists
7
+ it will return a default.
8
+ :param name: Name of setting
9
+ :type name: str
10
+ :param default: Value if setting is unfound
11
+ :returns: Setting's value
12
+ """
13
+ vars = name.split('.')
14
+ entry = None
15
+ for v in vars:
16
+ entry = getattr(settings, v, default) if entry is None else (entry[v] if v in entry else None)
17
+
18
+ return entry
@@ -0,0 +1,32 @@
1
+ import logging
2
+ import traceback
3
+
4
+ from rest_framework.response import Response
5
+ from rest_framework.views import exception_handler
6
+ from rest_framework import status
7
+ from rest_framework.exceptions import ValidationError, NotFound, ErrorDetail
8
+
9
+
10
+ def custom_exception_handler(e, context):
11
+ if isinstance(e, NotFound):
12
+ return Response({'message': e.__str__()}, status=status.HTTP_404_NOT_FOUND)
13
+
14
+ if isinstance(e, ValidationError):
15
+ errors = [str(error) for error in e.detail]
16
+ return Response({'message': 'Validation Failed', 'errors': errors, 'code': 0},
17
+ status=status.HTTP_412_PRECONDITION_FAILED)
18
+
19
+ response = exception_handler(e, context)
20
+
21
+ if response is not None:
22
+ return response
23
+
24
+ logging.getLogger('api').error(e)
25
+ logging.getLogger('api').error(traceback.format_exc())
26
+ return Response({'message': 'server error'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
27
+
28
+
29
+ class S3Exception(Exception):
30
+ def __init__(self, message):
31
+ super().__init__(message)
32
+
@@ -0,0 +1,98 @@
1
+ import os
2
+ import sys
3
+ import errno
4
+
5
+ from django.conf import settings
6
+ from django.core.files import locks
7
+
8
+
9
+ class FileLock:
10
+
11
+ class LockFailedException(Exception):
12
+ pass
13
+
14
+ def __init__(self, cron_class, silent, *args, **kwargs):
15
+ """
16
+ This method inits the class.
17
+ You should take care of getting all
18
+ nessesary thing from input parameters here
19
+ Base class processes
20
+ * self.job_name
21
+ * self.job_code
22
+ * self.parallel
23
+ * self.silent
24
+ for you. The rest is backend-specific.
25
+ """
26
+ self.job_name = cron_class.code
27
+ self.job_code = cron_class.code
28
+ self.parallel = getattr(cron_class, 'ALLOW_PARALLEL_RUNS', False)
29
+ self.silent = silent
30
+
31
+ def lock(self):
32
+ try:
33
+ lock_name = self.get_lock_name()
34
+ # need loop to avoid races on file unlinking
35
+ while True:
36
+ f = open(lock_name, 'wb+', 0)
37
+ locks.lock(f, locks.LOCK_EX | locks.LOCK_NB)
38
+ # Here is the Race:
39
+ # Previous process "A" is still running. Process "B" opens
40
+ # the file and then the process "A" finishes and deletes it.
41
+ # "B" locks the deleted file (by fd it already have) and runs,
42
+ # then the next process "C" creates _new_ file and locks it
43
+ # successfully while "B" is still running.
44
+ # We just need to check that "B" didn't lock a deleted file
45
+ # to avoid any problems. If process "C" have locked
46
+ # a new file wile "B" stats it then ok, let "B" quit and "C"
47
+ # run. We can still meet an attacker that permanently
48
+ # creates and deletes our file but we can't avoid problems
49
+ # in that case.
50
+ if os.path.isfile(lock_name):
51
+ st1 = os.fstat(f.fileno())
52
+ st2 = os.stat(lock_name)
53
+ if st1.st_ino == st2.st_ino:
54
+ f.write(bytes(str(os.getpid()).encode('utf-8')))
55
+ self.lockfile = f
56
+ return True
57
+ # else:
58
+ # retry. Don't unlink, next process might already use it.
59
+ f.close()
60
+
61
+ except IOError as e:
62
+ if e.errno in (errno.EACCES, errno.EAGAIN):
63
+ return False
64
+ else:
65
+ e = sys.exc_info()[1]
66
+ raise e
67
+ # TODO: perhaps on windows I need to catch different exception type
68
+
69
+ def release(self):
70
+ f = self.lockfile
71
+ # unlink before release lock to avoid race
72
+ # see comment in self.lock for description
73
+ os.unlink(f.name)
74
+ f.close()
75
+
76
+ def get_lock_name(self):
77
+ default_path = '/tmp'
78
+ path = getattr(settings, 'DJANGO_CRON_LOCKFILE_PATH', default_path)
79
+ if not os.path.isdir(path):
80
+ # let it die if failed, can't run further anyway
81
+ os.makedirs(path)
82
+
83
+ filename = self.job_name + '.lock'
84
+ return os.path.join(path, filename)
85
+
86
+ def lock_failed_message(self):
87
+ return "%s: lock found. Will try later." % self.job_name
88
+
89
+ def __enter__(self):
90
+ if self.parallel:
91
+ return
92
+ else:
93
+ if not self.lock():
94
+ raise self.LockFailedException(self.lock_failed_message())
95
+
96
+ def __exit__(self, type, value, traceback):
97
+ if not self.parallel:
98
+ self.release()
@@ -0,0 +1,43 @@
1
+ from rest_framework.pagination import PageNumberPagination
2
+ from rest_framework.response import Response
3
+ from collections import OrderedDict
4
+
5
+
6
+ class LargeResultsSetPagination(PageNumberPagination):
7
+ page_size = 10
8
+ page_size_query_param = 'per_page'
9
+ max_page_size = 100
10
+
11
+ def get_paginated_response(self, data):
12
+ return Response(OrderedDict([
13
+ ('total', self.page.paginator.count),
14
+ ('per_page', self.page.paginator.per_page),
15
+ ('current_page', self.page.number),
16
+ ('last_page', self.page.paginator.num_pages),
17
+ ('data', data)
18
+ ]))
19
+
20
+ def get_paginated_response_schema(self, schema):
21
+ return {
22
+ 'type': 'object',
23
+ 'properties': {
24
+ 'total': {
25
+ 'type': 'integer',
26
+ 'example': 123,
27
+ },
28
+ 'per_page': {
29
+ 'type': 'integer',
30
+ 'example': 123,
31
+ },
32
+ 'current_page': {
33
+ 'type': 'integer',
34
+ 'example': 123,
35
+ },
36
+ 'last_page': {
37
+ 'type': 'integer',
38
+ 'example': 123,
39
+ },
40
+ 'data': schema,
41
+ },
42
+ }
43
+
@@ -0,0 +1,15 @@
1
+ def is_empty(str_val: str) -> bool:
2
+ return not str_val or not (str_val and str_val.strip())
3
+
4
+ def safe_str_to_number(value):
5
+ try:
6
+ return int(value)
7
+ except ValueError:
8
+ pass
9
+
10
+ try:
11
+ return float(value)
12
+ except ValueError:
13
+ pass
14
+
15
+ raise ValueError(f"'{value}' is not a valid number.")
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.2
2
+ Name: base-api-utils
3
+ Version: 1.0.6
4
+ Summary: Django Rest Framework micro services common utilities
5
+ Author-email: Román Gutierrez <roman@tipit.net>
6
+ Project-URL: Homepage, https://github.com/fntechgit/base-api-utils
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: djangorestframework>=3.15.2
13
+ Requires-Dist: requests-oauthlib
14
+ Requires-Dist: requests
15
+ Requires-Dist: oauthlib
16
+ Requires-Dist: django_filters
17
+ Requires-Dist: django
18
+
19
+ # base-api-utils
20
+ DRF common utilities
21
+
22
+ ## Virtual Env
23
+
24
+ ````bash
25
+ $ python3 -m venv env
26
+
27
+ $ source env/bin/activate
28
+ ````
29
+
30
+ ## python setup
31
+
32
+ ````bash
33
+ sudo add-apt-repository ppa:deadsnakes/ppa -y
34
+ sudo apt-get -y -f install python3.7 python3-pip python3.7-dev python3.7-venv libpython3.7-dev python3-setuptools
35
+ sudo -H pip3 --default-timeout=50 install --upgrade pip
36
+ sudo -H pip3 install virtualenv
37
+ ````
38
+
39
+ ## Install reqs
40
+
41
+ ````
42
+ pip install -r requirements.txt
43
+ ````
44
+
45
+ ## Packaging
46
+
47
+ python3 -m pip install --upgrade build
48
+ python3 -m build
49
+
50
+ ## Uploading ( Test Py Pi)
51
+
52
+ python3 -m pip install --upgrade twine
53
+ python3 -m twine upload --repository testpypi dist/*
54
+
55
+ ## Publish to PyPI
56
+ twine upload dist/*
57
+
58
+ ## Install locally
59
+ pip install -i https://test.pypi.org/simple/ base-api-utils --no-deps
60
+
61
+ ## Install from GitHub
62
+ pip install git+https://github.com/fntechgit/base-api-utils
@@ -0,0 +1,28 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/base_api_utils/__init__.py
5
+ src/base_api_utils.egg-info/PKG-INFO
6
+ src/base_api_utils.egg-info/SOURCES.txt
7
+ src/base_api_utils.egg-info/dependency_links.txt
8
+ src/base_api_utils.egg-info/requires.txt
9
+ src/base_api_utils.egg-info/top_level.txt
10
+ src/base_api_utils/filters/__init__.py
11
+ src/base_api_utils/filters/base_filter.py
12
+ src/base_api_utils/security/__init__.py
13
+ src/base_api_utils/security/abstract_access_token_service.py
14
+ src/base_api_utils/security/abstract_oauth2_api_client.py
15
+ src/base_api_utils/security/access_token_service.py
16
+ src/base_api_utils/security/group_required.py
17
+ src/base_api_utils/security/oauth2_authentication.py
18
+ src/base_api_utils/security/oauth2_client_factory.py
19
+ src/base_api_utils/security/oauth2_scope_required.py
20
+ src/base_api_utils/security/shared.py
21
+ src/base_api_utils/serializers/__init__.py
22
+ src/base_api_utils/serializers/base_model_serializer.py
23
+ src/base_api_utils/utils/__init__.py
24
+ src/base_api_utils/utils/config.py
25
+ src/base_api_utils/utils/exceptions.py
26
+ src/base_api_utils/utils/file_lock.py
27
+ src/base_api_utils/utils/pagination.py
28
+ src/base_api_utils/utils/string.py
@@ -0,0 +1,6 @@
1
+ djangorestframework>=3.15.2
2
+ requests-oauthlib
3
+ requests
4
+ oauthlib
5
+ django_filters
6
+ django
@@ -0,0 +1 @@
1
+ base_api_utils