opengamedata-api-utils 0.0.1__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 (25) hide show
  1. opengamedata_api_utils-0.0.1/LICENSE +21 -0
  2. opengamedata_api_utils-0.0.1/PKG-INFO +69 -0
  3. opengamedata_api_utils-0.0.1/README.md +50 -0
  4. opengamedata_api_utils-0.0.1/pyproject.toml +37 -0
  5. opengamedata_api_utils-0.0.1/requirements.txt +5 -0
  6. opengamedata_api_utils-0.0.1/setup.cfg +4 -0
  7. opengamedata_api_utils-0.0.1/src/ogd/apis/HelloAPI.py +48 -0
  8. opengamedata_api_utils-0.0.1/src/ogd/apis/__init__.py +0 -0
  9. opengamedata_api_utils-0.0.1/src/ogd/apis/configs/ServerConfig.py +137 -0
  10. opengamedata_api_utils-0.0.1/src/ogd/apis/configs/__init__.py +0 -0
  11. opengamedata_api_utils-0.0.1/src/ogd/apis/models/APIRequest.py +101 -0
  12. opengamedata_api_utils-0.0.1/src/ogd/apis/models/APIResponse.py +173 -0
  13. opengamedata_api_utils-0.0.1/src/ogd/apis/models/__init__.py +0 -0
  14. opengamedata_api_utils-0.0.1/src/ogd/apis/models/enums/RESTType.py +8 -0
  15. opengamedata_api_utils-0.0.1/src/ogd/apis/models/enums/ResponseStatus.py +117 -0
  16. opengamedata_api_utils-0.0.1/src/ogd/apis/models/enums/__init__.py +0 -0
  17. opengamedata_api_utils-0.0.1/src/ogd/apis/resources/Hello.py +29 -0
  18. opengamedata_api_utils-0.0.1/src/ogd/apis/resources/ParamHello.py +29 -0
  19. opengamedata_api_utils-0.0.1/src/ogd/apis/utils/APIUtils.py +128 -0
  20. opengamedata_api_utils-0.0.1/src/ogd/apis/utils/__init__.py +0 -0
  21. opengamedata_api_utils-0.0.1/src/opengamedata_api_utils.egg-info/PKG-INFO +69 -0
  22. opengamedata_api_utils-0.0.1/src/opengamedata_api_utils.egg-info/SOURCES.txt +23 -0
  23. opengamedata_api_utils-0.0.1/src/opengamedata_api_utils.egg-info/dependency_links.txt +1 -0
  24. opengamedata_api_utils-0.0.1/src/opengamedata_api_utils.egg-info/requires.txt +4 -0
  25. opengamedata_api_utils-0.0.1/src/opengamedata_api_utils.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 opengamedata
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,69 @@
1
+ Metadata-Version: 2.1
2
+ Name: opengamedata-api-utils
3
+ Version: 0.0.1
4
+ Summary: Package of utilities for server-side scripts in OpenGameData.
5
+ Author: Ryan Wilkinson, Glenn Palmer, Daus Husaini
6
+ Author-email: Luke Swanson <superscription58@gmail.com>
7
+ Project-URL: Homepage, https://github.com/opengamedata/opengamedata-api-utils
8
+ Project-URL: Bug Tracker, https://github.com/opengamedata/opengamedata-api-utils/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.12
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: flask==2.3.3
16
+ Requires-Dist: flask-restful==0.3.10
17
+ Requires-Dist: requests==2.32.*
18
+ Requires-Dist: opengamedata-common>=2.0.0b6
19
+
20
+ # opengamedata-api-utils
21
+
22
+ Repository for utility server-side script and classes used by OpenGameData APIs.
23
+
24
+ ## Contents
25
+
26
+ ### Python Package
27
+
28
+ The `utils` repository contains a package that can be installed from `pip`, and places its classes under the `ogd.apis` namespace.
29
+ The available modules include:
30
+
31
+ * `HelloAPI.py` : Class for creating a "Hello, World" API to aid in testing deploys of other APIs.
32
+ * `schemas` : Contains a base class for server config schemas to handle version and debug-level config items
33
+ * `utils` : Contains helper classes for API requests and responses, as well as general-purpose parsing and setup functions.
34
+
35
+ ### Data Store Utilities
36
+
37
+ The `store` directory contains a script for reindexing a folder of OGD datasets after new datasets have been added.
38
+ This index is used by the FileAPI to check what datasets are available on the server.
39
+
40
+ ## Getting Started
41
+
42
+ ### Hello World of Flask
43
+
44
+ Steps to run:
45
+
46
+ 1. Check out latest `opengamedata-server`.
47
+ 2. Run `pip install -r requirements.txt` to ensure you've got flask.
48
+ 3. Run `flask run`.
49
+ 4. Open localhost:5000 or localhost:5000/hello to see some really basic text output from the Flask server.
50
+
51
+ If Flask doesn't run, it's possible you'd need to first export FLASK_APP as an environment variable, set to "wsgi" (so in Bash, export FLASK_APP=wsgi).
52
+ However, the script is named wsgi.py specifically because Flask is supposed to auto-detect it. So if this issue ever did come up, please ping Luke so he can look into it.
53
+
54
+ ## Testing in VS Code
55
+
56
+ Be sure to add the following to `.vscode/settings.json` in your local checkout:
57
+
58
+ ```json
59
+ "python.testing.unittestEnabled": true,
60
+ "python.testing.unittestArgs": [
61
+ "-v",
62
+ "-s",
63
+ "./tests/cases",
64
+ "-t",
65
+ "./",
66
+ "-p",
67
+ "t_*.py"
68
+ ],
69
+ ```
@@ -0,0 +1,50 @@
1
+ # opengamedata-api-utils
2
+
3
+ Repository for utility server-side script and classes used by OpenGameData APIs.
4
+
5
+ ## Contents
6
+
7
+ ### Python Package
8
+
9
+ The `utils` repository contains a package that can be installed from `pip`, and places its classes under the `ogd.apis` namespace.
10
+ The available modules include:
11
+
12
+ * `HelloAPI.py` : Class for creating a "Hello, World" API to aid in testing deploys of other APIs.
13
+ * `schemas` : Contains a base class for server config schemas to handle version and debug-level config items
14
+ * `utils` : Contains helper classes for API requests and responses, as well as general-purpose parsing and setup functions.
15
+
16
+ ### Data Store Utilities
17
+
18
+ The `store` directory contains a script for reindexing a folder of OGD datasets after new datasets have been added.
19
+ This index is used by the FileAPI to check what datasets are available on the server.
20
+
21
+ ## Getting Started
22
+
23
+ ### Hello World of Flask
24
+
25
+ Steps to run:
26
+
27
+ 1. Check out latest `opengamedata-server`.
28
+ 2. Run `pip install -r requirements.txt` to ensure you've got flask.
29
+ 3. Run `flask run`.
30
+ 4. Open localhost:5000 or localhost:5000/hello to see some really basic text output from the Flask server.
31
+
32
+ If Flask doesn't run, it's possible you'd need to first export FLASK_APP as an environment variable, set to "wsgi" (so in Bash, export FLASK_APP=wsgi).
33
+ However, the script is named wsgi.py specifically because Flask is supposed to auto-detect it. So if this issue ever did come up, please ping Luke so he can look into it.
34
+
35
+ ## Testing in VS Code
36
+
37
+ Be sure to add the following to `.vscode/settings.json` in your local checkout:
38
+
39
+ ```json
40
+ "python.testing.unittestEnabled": true,
41
+ "python.testing.unittestArgs": [
42
+ "-v",
43
+ "-s",
44
+ "./tests/cases",
45
+ "-t",
46
+ "./",
47
+ "-p",
48
+ "t_*.py"
49
+ ],
50
+ ```
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = [
3
+ "build == 1.2.*",
4
+ "setuptools == 74.1.*",
5
+ "setuptools-git-versioning == 2.0.*",
6
+ "twine == 5.1.*",
7
+ "wheel == 0.44.*"
8
+ ]
9
+ build-backend = "setuptools.build_meta"
10
+
11
+ [tool.setuptools-git-versioning]
12
+ enabled = true
13
+
14
+ [tool.setuptools.dynamic]
15
+ dependencies = {file = ["requirements.txt"]}
16
+
17
+ [project]
18
+ name = "opengamedata-api-utils"
19
+ dynamic = ["version", "dependencies"]
20
+ authors = [
21
+ { name="Luke Swanson", email="superscription58@gmail.com" },
22
+ { name="Ryan Wilkinson" },
23
+ { name="Glenn Palmer" },
24
+ { name="Daus Husaini" }
25
+ ]
26
+ description = "Package of utilities for server-side scripts in OpenGameData."
27
+ readme = "README.md"
28
+ requires-python = ">=3.12"
29
+ classifiers = [
30
+ "Programming Language :: Python :: 3",
31
+ "License :: OSI Approved :: MIT License",
32
+ "Operating System :: OS Independent",
33
+ ]
34
+
35
+ [project.urls]
36
+ "Homepage" = "https://github.com/opengamedata/opengamedata-api-utils"
37
+ "Bug Tracker" = "https://github.com/opengamedata/opengamedata-api-utils/issues"
@@ -0,0 +1,5 @@
1
+ flask==2.3.3
2
+ flask-restful==0.3.10
3
+ requests==2.32.*
4
+ # ogd packages
5
+ opengamedata-common>=2.0.0b6
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,48 @@
1
+ """
2
+ HelloAPI
3
+
4
+ Contains the HelloAPI class, which we register to all API apps as a way to test that the app is active.
5
+ """
6
+
7
+ # import libraries
8
+ from enum import StrEnum
9
+ from typing import Optional
10
+
11
+ # import 3rd-party libraries
12
+ from flask import Flask
13
+ from flask_restful import Resource, Api
14
+
15
+ # import OGD libraries
16
+
17
+ # import locals
18
+ from ogd.apis.resources.Hello import Hello
19
+ from ogd.apis.resources.ParamHello import ParamHello
20
+ from ogd.apis.configs.ServerConfig import ServerConfig
21
+ from ogd.apis.models.enums.ResponseStatus import ResponseStatus
22
+ from ogd.apis.models.enums.RESTType import RESTType
23
+ from ogd.apis.models.APIResponse import APIResponse
24
+
25
+ class HelloAPI:
26
+ class ENDPOINTS(StrEnum):
27
+ HELLO = 'hello'
28
+ VERSION = 'version'
29
+
30
+ @staticmethod
31
+ def register(app:Flask, server_config:ServerConfig, root_endpoint:Optional[ENDPOINTS | str]=None):
32
+ api = Api(app)
33
+ urls = ['/hello'] + (['/'] if root_endpoint == HelloAPI.ENDPOINTS.HELLO else [])
34
+ api.add_resource(Hello, *urls)
35
+ api.add_resource(ParamHello, '/p_hello/<name>')
36
+ urls = ['/version'] + (['/'] if root_endpoint == HelloAPI.ENDPOINTS.VERSION else [])
37
+ api.add_resource(HelloAPI.Version, *urls)
38
+
39
+ HelloAPI.server_config = server_config
40
+
41
+ class Version(Resource):
42
+ def get(self):
43
+ ret_val = APIResponse(
44
+ req_type = RESTType.GET,
45
+ val = { "version" : str(HelloAPI.server_config.Version) },
46
+ msg = "Successfully retrieved API version.",
47
+ status = ResponseStatus.OK)
48
+ return ret_val.AsDict
File without changes
@@ -0,0 +1,137 @@
1
+ """
2
+ ServerConfig
3
+
4
+ Contains a Config class for managing config data for server configurations.
5
+ """
6
+
7
+ # import standard libraries
8
+ import logging
9
+ from typing import Dict, Final, Optional, Self
10
+
11
+ # import 3rd-party libraries
12
+
13
+ # import OGD libraries
14
+ from ogd.common.configs.Config import Config
15
+ from ogd.common.models.SemanticVersion import SemanticVersion
16
+ from ogd.common.utils.typing import JSONMap, Map
17
+ from ogd.common.utils.Logger import Logger
18
+
19
+ # import local files
20
+
21
+ class ServerConfig(Config):
22
+ _DEFAULT_DEBUG_LEVEL : Final[int] = logging.INFO
23
+ _DEFAULT_VERSION : Final[str] = "UNKNOWN VERSION"
24
+
25
+ # *** BUILT-INS & PROPERTIES ***
26
+
27
+ def __init__(self, name:str,
28
+ debug_level:Optional[int], version:Optional[SemanticVersion],
29
+ other_elements:Optional[Map]=None):
30
+
31
+ unparsed_elements : Map = other_elements or {}
32
+
33
+ self._dbg_level : int
34
+ self._version : SemanticVersion
35
+
36
+ self._version = version if version is not None else self._parseVersion(unparsed_elements=unparsed_elements, schema_name=name)
37
+ self._dbg_level = debug_level if debug_level is not None else self._parseDebugLevel(unparsed_elements=unparsed_elements, schema_name=name)
38
+
39
+ super().__init__(name=name, other_elements=other_elements)
40
+
41
+ @property
42
+ def DebugLevel(self) -> int:
43
+ return self._dbg_level
44
+
45
+ @property
46
+ def Version(self) -> SemanticVersion:
47
+ return self._version
48
+
49
+ # *** IMPLEMENT ABSTRACT FUNCTIONS ***
50
+
51
+ @property
52
+ def AsMarkdown(self) -> str:
53
+ ret_val : str
54
+
55
+ ret_val = f"{self.Name}"
56
+ return ret_val
57
+
58
+ @property
59
+ def AsDict(self) -> JSONMap:
60
+ return {
61
+ "API_VERSION": str(self.Version),
62
+ "DEBUG_LEVEL": self.DebugLevel
63
+ }
64
+
65
+ @classmethod
66
+ def Default(cls):
67
+ return ServerConfig(
68
+ name="DefaultServerConfig",
69
+ debug_level=ServerConfig._DEFAULT_DEBUG_LEVEL,
70
+ version=SemanticVersion.FromString("0.0.0-Testing"),
71
+ other_elements={}
72
+ )
73
+
74
+ @classmethod
75
+ def _fromDict(cls, name:str, unparsed_elements:Map,
76
+ key_overrides:Optional[Dict[str, str]]=None,
77
+ default_override:Optional[Self]=None):
78
+ return ServerConfig(name=name, debug_level=None, version=None, other_elements=unparsed_elements)
79
+
80
+ @staticmethod
81
+ def _parseDebugLevel(unparsed_elements:Map, schema_name:Optional[str]=None) -> int:
82
+ ret_val : int
83
+
84
+ raw_level : str = ServerConfig.ParseElement(
85
+ unparsed_elements=unparsed_elements,
86
+ valid_keys=["DEBUG_LEVEL"],
87
+ to_type=[int, str],
88
+ default_value=ServerConfig._DEFAULT_DEBUG_LEVEL,
89
+ remove_target=True,
90
+ schema_name=schema_name
91
+ )
92
+ if isinstance(raw_level, int):
93
+ if raw_level in {logging.ERROR, logging.WARN, logging.INFO, logging.DEBUG}:
94
+ ret_val = raw_level
95
+ else:
96
+ ret_val = ServerConfig._DEFAULT_DEBUG_LEVEL
97
+ Logger.Log(f"Debug level had a value of {raw_level}, but this does not correspond to a valid logging level, defaulting to {ret_val}.", logging.WARNING)
98
+ elif isinstance(raw_level, str):
99
+ match raw_level.upper():
100
+ case "ERROR":
101
+ ret_val = logging.ERROR
102
+ case "WARNING" | "WARN":
103
+ ret_val = logging.WARN
104
+ case "INFO":
105
+ ret_val = logging.INFO
106
+ case "DEBUG":
107
+ ret_val = logging.DEBUG
108
+ case _:
109
+ ret_val = ServerConfig._DEFAULT_DEBUG_LEVEL
110
+ Logger.Log(f"Config debug level had unexpected value {raw_level}, defaulting to {ret_val}.", logging.WARNING)
111
+ else:
112
+ ret_val = ServerConfig._DEFAULT_DEBUG_LEVEL
113
+ Logger.Log(f"Config debug level was unexpected type {type(raw_level)}, defaulting to {ret_val}.", logging.WARNING)
114
+
115
+ return ret_val
116
+
117
+ @staticmethod
118
+ def _parseVersion(unparsed_elements:Map, schema_name:Optional[str]=None) -> SemanticVersion:
119
+ ret_val : SemanticVersion
120
+
121
+ raw_version = ServerConfig.ParseElement(
122
+ unparsed_elements=unparsed_elements,
123
+ valid_keys=["API_VERSION"],
124
+ to_type=[int, str],
125
+ default_value=ServerConfig._DEFAULT_VERSION,
126
+ remove_target=True,
127
+ schema_name=schema_name
128
+ )
129
+ if isinstance(raw_version, int):
130
+ ret_val = SemanticVersion(major=raw_version)
131
+ elif isinstance(raw_version, str):
132
+ ret_val = SemanticVersion.FromString(semver=raw_version)
133
+ else:
134
+ ret_val = SemanticVersion.FromString(str(raw_version))
135
+ Logger.Log(f"Config version was unexpected type {type(raw_version)}, defaulting to SemanticVersion(str(version))={ret_val}.", logging.WARN)
136
+
137
+ return ret_val
@@ -0,0 +1,101 @@
1
+ import logging
2
+ from typing import Any, Dict, Optional
3
+
4
+ import requests
5
+ from flask import current_app
6
+
7
+ from ogd.apis.models.enums.RESTType import RESTType
8
+ from ogd.apis.models.enums.ResponseStatus import ResponseStatus
9
+ from ogd.apis.models.APIResponse import APIResponse
10
+
11
+ class APIRequest:
12
+ def __init__(self, url:str, request_type:str | RESTType, params:Optional[Dict[str, Any]]=None, body:Optional[Dict[str, Any]]=None, timeout:int=1):
13
+ """Utility function to make it easier to send requests to a remote server during unit testing.
14
+
15
+ This function does some basic sanity checking of the target URL,
16
+ maps the request type to the appropriate `requests` function call,
17
+ and performs basic error handling to notify what error occurred.
18
+
19
+ :param url: The target URL for the web request
20
+ :type url: str
21
+ :param request: Whether to perform a "GET", "POST", or "PUT" request
22
+ :type request: str
23
+ :param params: A mapping of request parameter names to values. Defaults to {}
24
+ :type params: Dict[str, Any], optional
25
+ :param body: The body of the request to send. Defaults to None
26
+ :type body: Dict[str, Any], optional
27
+ :param logger: A logger to use for debug/error outputs. Defaults to None
28
+ :type logger: logging.Logger, optional
29
+ :raises err: Currently, any exceptions that occur during the request will be raised up.
30
+ If verbose logging is on, a simple debug message indicating the request type and URL is printed first.
31
+ :return: The `Response` object from the request, or None if an error occurred.
32
+ :rtype: requests.Response
33
+ """
34
+ params = params or {}
35
+
36
+ self._request_type : RESTType
37
+
38
+ if not (url.startswith("http://") or url.startswith("https://")):
39
+ url = f"https://{url}"
40
+ match request_type:
41
+ case RESTType():
42
+ self._request_type = request_type
43
+ case str():
44
+ try:
45
+ self._request_type = RESTType[request_type.upper()]
46
+ except KeyError:
47
+ current_app.logger.warning(f"Bad request type {request_type}, defaulting to GET")
48
+ self._request_type = RESTType.GET
49
+ case _:
50
+ raise TypeError(f"request_type for APIRequest was invalid type {type(request_type)}")
51
+
52
+ self._url = url
53
+ self._params = params
54
+ self._body = body
55
+ self._timeout = timeout
56
+
57
+ def __str__(self) -> str:
58
+ return f"Request: {self._request_type} {self._url}"
59
+
60
+ def Execute(self, logger:Optional[logging.Logger]=None, retry:int=0) -> APIResponse:
61
+ ret_val : APIResponse
62
+
63
+ if logger is None and current_app:
64
+ logger = current_app.logger
65
+
66
+ response : requests.Response
67
+ try:
68
+ match (self._request_type):
69
+ case RESTType.GET:
70
+ response = requests.get( self._url, params=self._params, timeout=self._timeout)
71
+ case RESTType.POST:
72
+ response = requests.post(self._url, params=self._params, data=self._body, timeout=self._timeout)
73
+ case RESTType.PUT:
74
+ response = requests.put( self._url, params=self._params, data=self._body, timeout=self._timeout)
75
+ case _:
76
+ if logger:
77
+ logger.warning(f"Bad request type {self._request_type}, defaulting to GET")
78
+ response = requests.get(self._url, params=self._params, timeout=self._timeout)
79
+ except requests.exceptions.ReadTimeout:
80
+ if retry < 5:
81
+ if logger:
82
+ logger.error(f"Timeout error executing {self}, trying again...")
83
+ return self.Execute(logger=logger, retry=retry+1)
84
+ else:
85
+ if logger:
86
+ logger.error(f"Timeout error executing {self}.")
87
+ return APIResponse(req_type=self._request_type, val=None, msg="Could not retrieve results, server timed out!", status=ResponseStatus.GATEWAY_TIMEOUT)
88
+ except Exception as err:
89
+ if logger:
90
+ logger.error(f"Error on {self._request_type} request to {self._url} : {err}")
91
+ return APIResponse(req_type=self._request_type, val=None, msg="Could not retrieve results, encountered an unexpected error while executing request!", status=ResponseStatus.INTERNAL_ERR)
92
+ else:
93
+ ret_val = APIResponse.FromResponse(response)
94
+ if logger:
95
+ out = logger.debug if ret_val.Status == ResponseStatus.OK else logger.warning
96
+ out(f"Request sent to: {self._url}, with params {self._params}")
97
+ out(f"Response received from: {self._url}")
98
+ out(f" Status: {ret_val.Status}")
99
+ out(f" Msg: {ret_val.Message}")
100
+ out(f" Value: {ret_val.Value}")
101
+ return ret_val
@@ -0,0 +1,173 @@
1
+ """
2
+ APIResponse
3
+
4
+ Contains class for representing a response from an OGD API,
5
+ as well as utility enums used by the APIResponse class.
6
+ """
7
+
8
+ # import standard libraries
9
+ import json
10
+ import logging
11
+ from typing import Any, Dict, Optional
12
+
13
+ # import 3rd-party libraries
14
+ import requests
15
+ from flask import Response
16
+
17
+ # import OGD libraries
18
+ from ogd.common.utils.typing import Map
19
+ from ogd.common.utils.Logger import Logger
20
+
21
+ # Import local files
22
+ from ogd.apis.models.enums.RESTType import RESTType
23
+ from ogd.apis.models.enums.ResponseStatus import ResponseStatus
24
+
25
+ class APIResponse:
26
+ def __init__(self, req_type:Optional[RESTType | str], val:Optional[Map], msg:str, status:ResponseStatus):
27
+ self._type : Optional[RESTType]
28
+ self._val : Optional[Map]
29
+
30
+ match req_type:
31
+ case RESTType():
32
+ self._type = req_type
33
+ case str():
34
+ self._type = RESTType[req_type]
35
+ case _:
36
+ self._type = None
37
+ match val:
38
+ case dict() | None:
39
+ self._val = val
40
+ case _:
41
+ try:
42
+ self._val = json.loads(str(val))
43
+ except json.decoder.JSONDecodeError as err:
44
+ abbreviated_val = f"{str(val)[:20]}..." if len(str(val)) > 20 else str(val)
45
+ _msg = f"API response 'value' field contained value '{abbreviated_val}' with invalid type {type(val)}, which could not be converted to a dictionary. Attempting to do so resulted in error:\n{err}\nThe value field will be left blank."
46
+ Logger.Log(_msg, logging.ERROR)
47
+ self._val = None
48
+ self._msg : str = msg
49
+ self._status : ResponseStatus = status
50
+
51
+ def __str__(self):
52
+ return f"{str(self.Type)} request: {self.Status}\n{self.Message}\nValues: {self.Value}"
53
+
54
+ @staticmethod
55
+ def Default(req_type:RESTType):
56
+ return APIResponse(
57
+ req_type=req_type,
58
+ val=None,
59
+ msg="",
60
+ status=ResponseStatus.NONE
61
+ )
62
+
63
+ @staticmethod
64
+ def FromResponse(result:requests.Response) -> "APIResponse":
65
+ ret_val : APIResponse
66
+
67
+ try:
68
+ raw = result.json()
69
+ ret_val = APIResponse(req_type=raw.get("type"), val=raw.get("val"), msg=raw.get("msg"), status=ResponseStatus(result.status_code))
70
+ except requests.exceptions.JSONDecodeError:
71
+ ret_val = APIResponse(req_type=None, val=None, msg=result.text, status=ResponseStatus(result.status_code))
72
+
73
+ return ret_val
74
+
75
+ @staticmethod
76
+ def FromDict(all_elements:Dict[str, Any], status:Optional[ResponseStatus]=None) -> Optional["APIResponse"]:
77
+ ret_val : Optional["APIResponse"] = None
78
+
79
+ _type_raw = all_elements.get("type", None)
80
+ _val_raw = all_elements.get("val", None)
81
+ _msg = all_elements.get("msg", "NOT FOUND")
82
+ _status_raw = all_elements.get("status", None)
83
+ try:
84
+ _type = RESTType[str(_type_raw).upper()] if _type_raw else None
85
+ _val = _val_raw if isinstance(_val_raw, dict) else json.loads(str(_val_raw)) if _val_raw is not None else None
86
+ _status = ResponseStatus[str(_status_raw).upper()] if _status_raw else (status or ResponseStatus.NONE)
87
+ except KeyError:
88
+ pass
89
+ else:
90
+ ret_val = APIResponse(req_type=_type, val=_val, msg=_msg, status=_status)
91
+ return ret_val
92
+
93
+ @property
94
+ def Type(self) -> Optional[RESTType]:
95
+ """Property for the type of REST request
96
+
97
+ :return: A RESTType representing the type of REST request
98
+ :rtype: _type_
99
+ """
100
+ return self._type
101
+
102
+ @property
103
+ def Value(self) -> Optional[Map]:
104
+ """Property for the value of the request result.
105
+
106
+ :return: Some value, of any type, returned from the request.
107
+ :rtype: Any
108
+ """
109
+ return self._val
110
+ @Value.setter
111
+ def Value(self, new_val:Optional[Map]):
112
+ self._val = new_val
113
+
114
+
115
+ @property
116
+ def Message(self) -> str:
117
+ """Property for the message associated with a request result.
118
+
119
+ :return: A string message giving details on the result of the request.
120
+ :rtype: str
121
+ """
122
+ return self._msg
123
+ @Message.setter
124
+ def Message(self, new_msg:str):
125
+ self._msg = new_msg
126
+
127
+ @property
128
+ def Status(self) -> ResponseStatus:
129
+ """Property for the status of the request.
130
+
131
+ :return: A ResponseStatus indicating whether request is/was successful, incomplete, failed, etc.
132
+ :rtype: ResponseStatus
133
+ """
134
+ return self._status
135
+ @property
136
+ def OK(self) -> bool:
137
+ """Property indicating whether the APIResponse was successful or not.
138
+
139
+ Technically, this includes both a true "OK" response and any other 200-range response.
140
+
141
+ :return: True if the APIResponse status is in the 200 range, otherwise False.
142
+ :rtype: bool
143
+ """
144
+ return self.Status in ResponseStatus.SuccessStatuses()
145
+
146
+ @property
147
+ def AsDict(self):
148
+ return {
149
+ "type" : str(self._type),
150
+ "val" : self._val,
151
+ "msg" : self._msg,
152
+ }
153
+
154
+ @property
155
+ def AsJSON(self):
156
+ return json.dumps(self.AsDict)
157
+
158
+ @property
159
+ def AsFlaskResponse(self) -> Response:
160
+ return Response(response=self.AsJSON, status=self.Status.value, mimetype='application/json')
161
+
162
+ def RequestErrored(self, msg:str, status:Optional[ResponseStatus]=None):
163
+ self._status = status if status is not None and status in ResponseStatus.ClientErrors() else ResponseStatus.BAD_REQUEST
164
+ self.Message = f"ERROR: {msg}"
165
+
166
+ def ServerErrored(self, msg:str, status:Optional[ResponseStatus]=None):
167
+ self._status = status if status is not None and status in ResponseStatus.ServerErrors() else ResponseStatus.INTERNAL_ERR
168
+ self.Message = f"SERVER ERROR: {msg}"
169
+
170
+ def RequestSucceeded(self, msg:str, val:Optional[Map]):
171
+ self._status = ResponseStatus.OK
172
+ self.Message = f"SUCCESS: {msg}"
173
+ self.Value = val
@@ -0,0 +1,8 @@
1
+ from enum import StrEnum
2
+
3
+ class RESTType(StrEnum):
4
+ """Simple enumerated type to track type of a REST request.
5
+ """
6
+ GET = "GET"
7
+ POST = "POST"
8
+ PUT = "PUT"
@@ -0,0 +1,117 @@
1
+ from enum import IntEnum
2
+ from typing import Set
3
+
4
+ class ResponseStatus(IntEnum):
5
+ """Enumerated type to track the status of an API request result.
6
+ """
7
+ NONE = 1
8
+ CONTINUE = 100
9
+ OK = 200
10
+ MULTI_CHOICES = 300
11
+ BAD_REQUEST = 400
12
+ INTERNAL_ERR = 500
13
+
14
+ # 100s
15
+ SWITCHING_PROTOCOLS = 101
16
+ PROCESSING = 102
17
+ EARLY_HINTS = 103
18
+
19
+ # 200s
20
+ CREATED = 201
21
+ ACCEPTED = 202
22
+ NON_AUTHORITATIVE = 203
23
+ NO_CONTENT = 204
24
+ RESET = 205
25
+ PARTIAL = 206
26
+ MULTI_STATUS = 207
27
+ ALREADY_REPORTED = 208
28
+ IM_USED = 226
29
+
30
+ # 300s
31
+ MOVED = 301
32
+ FOUND = 302
33
+ SEE_OTHER = 303
34
+ NOT_MODIFIED = 304
35
+ TEMPORARY_REDIR = 307
36
+ PERMANENT_REDIR = 308
37
+
38
+ # 400s
39
+ UNAUTHORIZED = 401
40
+ PAYMENT_REQUIRED = 402
41
+ FORBIDDEN = 403
42
+ NOT_FOUND = 404
43
+ METHOD_NOT_ALLOWED = 405
44
+ NOT_ACCEPTABLE = 406
45
+ PROXY_AUTH_REQUIRED = 407
46
+ REQUEST_TIMEOUT = 408
47
+ CONFLICT = 409
48
+ GONE = 410
49
+ LENGTH_REQUIRED = 411
50
+ PRECONDITION_FAILED = 412
51
+ CONTENT_TOO_LARGE = 413
52
+ URI_TOO_LONG = 414
53
+ UNSUPPORTED_MEDIA = 415
54
+ RANGE_INVALID = 416
55
+ EXPECTATION_FAIL = 417
56
+ IM_A_TEAPOT = 418
57
+ MISDIRECTED = 421
58
+ TOO_EARLY = 425
59
+ UPGRADE_REQUIRED = 426
60
+ PRECONDITION_REQUIRED = 428
61
+ TOO_MANY_REQUESTS = 429
62
+ HEADERS_TOO_LARGE = 431
63
+ ILLEGAL = 451
64
+
65
+ # 400s WebDAV
66
+ UNPROCESSABLE = 422
67
+ LOCKED = 423
68
+ FAILED_DEPENDENCY = 424
69
+
70
+ # 500s
71
+ NOT_IMPLEMENTED = 501
72
+ BAD_GATEWAY = 502
73
+ UNAVAILABLE = 503
74
+ GATEWAY_TIMEOUT = 504
75
+ UNSUPPORTED_HTTP_VERSION = 505
76
+ VARIANT_NEGOTIATES = 506
77
+ NOT_EXTENDED = 510
78
+ NETWORK_AUTH_REQUIRED = 511
79
+
80
+ # 500s WebDAV
81
+ INSUFFICIENT_STORAGE = 507
82
+ LOOP_DETECTED = 508
83
+
84
+ @staticmethod
85
+ def SuccessStatuses() -> Set["ResponseStatus"]:
86
+ """Gets the set of valid 400-level "client" error responses.
87
+
88
+ :return: The set of valid 400-level "client" error responses.
89
+ :rtype: Set[ResponseStatus]
90
+ """
91
+ return {status for status in set(ResponseStatus) if status in range(200, 300)}
92
+
93
+ @staticmethod
94
+ def ClientErrors() -> Set["ResponseStatus"]:
95
+ """Gets the set of valid 400-level "client" error responses.
96
+
97
+ :return: The set of valid 400-level "client" error responses.
98
+ :rtype: Set[ResponseStatus]
99
+ """
100
+ return {status for status in set(ResponseStatus) if status in range(400, 500)}
101
+
102
+ @staticmethod
103
+ def ServerErrors() -> Set["ResponseStatus"]:
104
+ """Gets the set of valid 500-level "server" error responses.
105
+
106
+ :return: The set of valid 500-level "server" error responses.
107
+ :rtype: Set[ResponseStatus]
108
+ """
109
+ return {status for status in set(ResponseStatus) if status in range(500, 600)}
110
+
111
+ def __str__(self):
112
+ """Stringify function for ResponseStatus objects.
113
+
114
+ :return: Simple string version of the name of a ResponseStatus
115
+ :rtype: _type_
116
+ """
117
+ return self.name
@@ -0,0 +1,29 @@
1
+ # import 3rd-party libraries
2
+ from flask_restful import Resource
3
+
4
+ from ogd.apis.models.APIResponse import APIResponse, RESTType, ResponseStatus
5
+
6
+ class Hello(Resource):
7
+ def get(self):
8
+ ret_val = APIResponse(
9
+ req_type = RESTType.GET,
10
+ val = None,
11
+ msg = "Hello! You GETted successfully!",
12
+ status = ResponseStatus.OK)
13
+ return ret_val.AsDict
14
+
15
+ def post(self):
16
+ ret_val = APIResponse(
17
+ req_type = RESTType.POST,
18
+ val = None,
19
+ msg = "Hello! You POSTed successfully!",
20
+ status = ResponseStatus.OK)
21
+ return ret_val.AsDict
22
+
23
+ def put(self):
24
+ ret_val = APIResponse(
25
+ req_type = RESTType.PUT,
26
+ val = None,
27
+ msg = "Hello! You PUTted successfully!",
28
+ status = ResponseStatus.OK)
29
+ return ret_val.AsDict
@@ -0,0 +1,29 @@
1
+ # import 3rd-party libraries
2
+ from flask_restful import Resource
3
+
4
+ from ogd.apis.models.APIResponse import APIResponse, RESTType, ResponseStatus
5
+
6
+ class ParamHello(Resource):
7
+ def get(self, name):
8
+ ret_val = APIResponse(
9
+ req_type = RESTType.GET,
10
+ val = None,
11
+ msg = f"Hello {name}! You GETted successfully!",
12
+ status = ResponseStatus.OK)
13
+ return ret_val.AsDict
14
+
15
+ def post(self, name):
16
+ ret_val = APIResponse(
17
+ req_type = RESTType.POST,
18
+ val = None,
19
+ msg = f"Hello {name}! You POSTed successfully!",
20
+ status = ResponseStatus.OK)
21
+ return ret_val.AsDict
22
+
23
+ def put(self, name):
24
+ ret_val = APIResponse(
25
+ req_type = RESTType.PUT,
26
+ val = None,
27
+ msg = f"Hello {name}! You PUTted successfully!",
28
+ status = ResponseStatus.OK)
29
+ return ret_val.AsDict
@@ -0,0 +1,128 @@
1
+ """
2
+ APIUtils
3
+
4
+ Contains general utility functions for common tasks when setting up our flask/flask-restful API functions.
5
+ In particular, has functions to assist in parsing certain kinds of data, and for generating OGD-core objects.
6
+ """
7
+
8
+ # import standard libraries
9
+ import json
10
+ from json.decoder import JSONDecodeError
11
+ from logging import Logger
12
+ from typing import Any, List, Optional
13
+ from urllib import parse
14
+
15
+ def parse_list(list_str:str, logger:Optional[Logger]=None) -> Optional[List[Any]]:
16
+ """Simple utility to parse a string containing a bracketed list into a Python list.
17
+ Returns None if the list was empty
18
+
19
+ :param list_str: _description_
20
+ :type list_str: str
21
+ :return: A list parsed from the input string, or None if the string list was invalid or empty.
22
+ :rtype: Union[List[Any], None]
23
+ """
24
+ ret_val : Optional[List[Any]] = None
25
+ try:
26
+ ret_val = json.loads(list_str)
27
+ except JSONDecodeError as e:
28
+ if logger:
29
+ logger.warning(f"Could not parse '{list_str}' as a list, format was not valid!\nGot Error {e}")
30
+ else:
31
+ if ret_val is not None and len(ret_val) == 0:
32
+ # If we had empty list, just treat as null.
33
+ ret_val = None
34
+ return ret_val
35
+
36
+ def urljoin(base:str, url:str, ignore_base_file:bool=False, allow_fragments:bool=True):
37
+ """Custom variation of the `urllib.parse.urljoin` function provided by Python.
38
+
39
+ By default, this version allows filenames in the base path to remain in the joined path.
40
+
41
+ This is useful for working with Flask apps, particularly on Apache.
42
+ Specifically, unless you alias things in Apache, you'll have `app.py` or `app.wsgi` in the URL.
43
+ For a Flask API, then, you'll likely be joining a base URL like `"https://host.of.app/path/to/app.wsgi"` with an endpoint, call it `"endpoint"`.
44
+ Under `urllib.parse.urljoin`, you'll get `"https://host.of.app/path/to/endpoint"`.
45
+ With _this_ function, setting `ignore_base_file=False`, you'll get `"http://host.of.app/path/to/app.wsgi/endpoint"` as desired.
46
+
47
+ When `ignore_base_file=True`, this function directly falls back to use `urllib.parse.urljoin`.
48
+
49
+ :param base: The base URL, onto which the `url` parameter is joined.
50
+ :type base: str
51
+ :param url: The URL to be joined onto the given base URL.
52
+ :type url: str
53
+ :param ignore_base_file: Whether to ignore filenames in the base URL.
54
+ When True, such filenames are removed from the joined URL.
55
+ For example, joining `https://host.of.app/path/to/app.wsgi` with `endpoint` would yield `https://host.of.app/path/to/endpoint`.
56
+ When False, such filenames are included in the joined URL.
57
+ :type ignore_base_file: bool
58
+ :param allow_fragments: Whether to allow fragment parts in the URLs.
59
+ This is only used when `ignore_base_file=True`, in which case this function falls back on `urllib.parse.urljoin` and `allow_fragments` is passed to that function call.
60
+ :type ignore_base_file: bool
61
+ """
62
+ if ignore_base_file:
63
+ return parse.urljoin(base=base, url=url, allow_fragments=allow_fragments)
64
+ else:
65
+ # Make sure we have a scheme
66
+ if not (base.startswith("http://") or base.startswith("https://")):
67
+ base = f"https://{base}"
68
+ # If base ends with a /, remove it so we don't double-up when joining
69
+ if base.endswith("/"):
70
+ base = base[:-1]
71
+ # If url starts with a /, remove it so we don't double-up when joining
72
+ if url.startswith("/"):
73
+ url = url[1:]
74
+ return f"{base}/{url}"
75
+
76
+ # def gen_interface(game_id, core_config:ConfigSchema, logger:Optional[Logger]=None) -> Optional[Interface]:
77
+ # """Utility to set up an Interface object for use by the API, given a game_id.
78
+
79
+ # :param game_id: _description_
80
+ # :type game_id: _type_
81
+ # :return: _description_
82
+ # :rtype: _type_
83
+ # """
84
+ # ret_val = None
85
+
86
+ # _game_source : DataTableConfig = core_config.GameSourceMap.get(game_id, DataTableConfig.Default())
87
+
88
+ # if _game_source.Source is not None:
89
+ # # set up interface and request
90
+ # match _game_source.Source.Type.upper():
91
+ # case "MYSQL":
92
+ # ret_val = MySQLInterface(game_id, config=_game_source, fail_fast=False)
93
+ # if logger:
94
+ # logger.info(f"Using MySQLInterface for {game_id}")
95
+ # case "BIGQUERY":
96
+ # if logger:
97
+ # logger.info(f"Generating BigQueryInterface for {game_id}, from directory {os.getcwd()}...")
98
+ # ret_val = BigQueryInterface(game_id=game_id, config=_game_source, fail_fast=False)
99
+ # if logger:
100
+ # logger.info("Done")
101
+ # case _:
102
+ # ret_val = MySQLInterface(game_id, config=_game_source, fail_fast=False)
103
+ # if logger:
104
+ # logger.warning(f"Could not find a valid interface for {game_id}, defaulting to MySQL!")
105
+ # return ret_val
106
+
107
+ # def gen_coding_interface(game_id) -> Optional[CodingInterface]:
108
+ # """Utility to set up an Interface object for use by the API, given a game_id.
109
+
110
+ # :param game_id: _description_
111
+ # :type game_id: _type_
112
+ # :return: _description_
113
+ # :rtype: _type_
114
+ # """
115
+ # ret_val = None
116
+
117
+ # _core_config = ConfigSchema(name="Core Config", all_elements=core_settings)
118
+ # _game_source : GameSourceSchema = _core_config.GameSourceMap.get(game_id, GameSourceSchema.EmptySchema())
119
+
120
+ # if _game_source.Source is not None:
121
+ # # set up interface and request
122
+ # if _game_source.Source.Type == "BigQuery":
123
+ # ret_val = BigQueryCodingInterface(game_id=game_id, config=_core_config)
124
+ # current_app.logger.info(f"Using BigQueryCodingInterface for {game_id}")
125
+ # else:
126
+ # ret_val = BigQueryCodingInterface(game_id=game_id, config=_core_config)
127
+ # current_app.logger.warning(f"Could not find a valid interface for {game_id}, defaulting to BigQuery!")
128
+ # return ret_val
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.1
2
+ Name: opengamedata-api-utils
3
+ Version: 0.0.1
4
+ Summary: Package of utilities for server-side scripts in OpenGameData.
5
+ Author: Ryan Wilkinson, Glenn Palmer, Daus Husaini
6
+ Author-email: Luke Swanson <superscription58@gmail.com>
7
+ Project-URL: Homepage, https://github.com/opengamedata/opengamedata-api-utils
8
+ Project-URL: Bug Tracker, https://github.com/opengamedata/opengamedata-api-utils/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.12
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: flask==2.3.3
16
+ Requires-Dist: flask-restful==0.3.10
17
+ Requires-Dist: requests==2.32.*
18
+ Requires-Dist: opengamedata-common>=2.0.0b6
19
+
20
+ # opengamedata-api-utils
21
+
22
+ Repository for utility server-side script and classes used by OpenGameData APIs.
23
+
24
+ ## Contents
25
+
26
+ ### Python Package
27
+
28
+ The `utils` repository contains a package that can be installed from `pip`, and places its classes under the `ogd.apis` namespace.
29
+ The available modules include:
30
+
31
+ * `HelloAPI.py` : Class for creating a "Hello, World" API to aid in testing deploys of other APIs.
32
+ * `schemas` : Contains a base class for server config schemas to handle version and debug-level config items
33
+ * `utils` : Contains helper classes for API requests and responses, as well as general-purpose parsing and setup functions.
34
+
35
+ ### Data Store Utilities
36
+
37
+ The `store` directory contains a script for reindexing a folder of OGD datasets after new datasets have been added.
38
+ This index is used by the FileAPI to check what datasets are available on the server.
39
+
40
+ ## Getting Started
41
+
42
+ ### Hello World of Flask
43
+
44
+ Steps to run:
45
+
46
+ 1. Check out latest `opengamedata-server`.
47
+ 2. Run `pip install -r requirements.txt` to ensure you've got flask.
48
+ 3. Run `flask run`.
49
+ 4. Open localhost:5000 or localhost:5000/hello to see some really basic text output from the Flask server.
50
+
51
+ If Flask doesn't run, it's possible you'd need to first export FLASK_APP as an environment variable, set to "wsgi" (so in Bash, export FLASK_APP=wsgi).
52
+ However, the script is named wsgi.py specifically because Flask is supposed to auto-detect it. So if this issue ever did come up, please ping Luke so he can look into it.
53
+
54
+ ## Testing in VS Code
55
+
56
+ Be sure to add the following to `.vscode/settings.json` in your local checkout:
57
+
58
+ ```json
59
+ "python.testing.unittestEnabled": true,
60
+ "python.testing.unittestArgs": [
61
+ "-v",
62
+ "-s",
63
+ "./tests/cases",
64
+ "-t",
65
+ "./",
66
+ "-p",
67
+ "t_*.py"
68
+ ],
69
+ ```
@@ -0,0 +1,23 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ requirements.txt
5
+ src/ogd/apis/HelloAPI.py
6
+ src/ogd/apis/__init__.py
7
+ src/ogd/apis/configs/ServerConfig.py
8
+ src/ogd/apis/configs/__init__.py
9
+ src/ogd/apis/models/APIRequest.py
10
+ src/ogd/apis/models/APIResponse.py
11
+ src/ogd/apis/models/__init__.py
12
+ src/ogd/apis/models/enums/RESTType.py
13
+ src/ogd/apis/models/enums/ResponseStatus.py
14
+ src/ogd/apis/models/enums/__init__.py
15
+ src/ogd/apis/resources/Hello.py
16
+ src/ogd/apis/resources/ParamHello.py
17
+ src/ogd/apis/utils/APIUtils.py
18
+ src/ogd/apis/utils/__init__.py
19
+ src/opengamedata_api_utils.egg-info/PKG-INFO
20
+ src/opengamedata_api_utils.egg-info/SOURCES.txt
21
+ src/opengamedata_api_utils.egg-info/dependency_links.txt
22
+ src/opengamedata_api_utils.egg-info/requires.txt
23
+ src/opengamedata_api_utils.egg-info/top_level.txt
@@ -0,0 +1,4 @@
1
+ flask==2.3.3
2
+ flask-restful==0.3.10
3
+ requests==2.32.*
4
+ opengamedata-common>=2.0.0b6