clue-api 1.0.0.dev7__py3-none-any.whl
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.
- clue/.gitignore +21 -0
- clue/__init__.py +0 -0
- clue/api/__init__.py +211 -0
- clue/api/base.py +99 -0
- clue/api/v1/__init__.py +82 -0
- clue/api/v1/actions.py +92 -0
- clue/api/v1/auth.py +243 -0
- clue/api/v1/configs.py +83 -0
- clue/api/v1/fetchers.py +94 -0
- clue/api/v1/lookup.py +221 -0
- clue/api/v1/registration.py +109 -0
- clue/api/v1/static.py +94 -0
- clue/app.py +166 -0
- clue/cache/__init__.py +129 -0
- clue/common/__init__.py +0 -0
- clue/common/classification.py +1006 -0
- clue/common/classification.yml +130 -0
- clue/common/dict_utils.py +130 -0
- clue/common/exceptions.py +199 -0
- clue/common/forge.py +152 -0
- clue/common/json_utils.py +10 -0
- clue/common/list_utils.py +11 -0
- clue/common/logging/__init__.py +291 -0
- clue/common/logging/audit.py +157 -0
- clue/common/logging/format.py +42 -0
- clue/common/regex.py +31 -0
- clue/common/str_utils.py +213 -0
- clue/common/swagger.py +139 -0
- clue/common/uid.py +47 -0
- clue/config.py +60 -0
- clue/constants/__init__.py +0 -0
- clue/constants/supported_types.py +38 -0
- clue/cronjobs/__init__.py +30 -0
- clue/cronjobs/plugins.py +32 -0
- clue/error.py +129 -0
- clue/gunicorn_config.py +29 -0
- clue/healthz.py +74 -0
- clue/helper/discover.py +53 -0
- clue/helper/headers.py +30 -0
- clue/helper/oauth.py +128 -0
- clue/models/__init__.py +0 -0
- clue/models/actions.py +243 -0
- clue/models/config.py +456 -0
- clue/models/fetchers.py +136 -0
- clue/models/graph.py +162 -0
- clue/models/model_list.py +52 -0
- clue/models/network.py +430 -0
- clue/models/results/__init__.py +34 -0
- clue/models/results/base.py +10 -0
- clue/models/results/graph.py +26 -0
- clue/models/results/image.py +22 -0
- clue/models/results/status.py +55 -0
- clue/models/results/validation.py +57 -0
- clue/models/selector.py +67 -0
- clue/models/utils.py +52 -0
- clue/models/validators.py +19 -0
- clue/patched.py +8 -0
- clue/plugin/__init__.py +1008 -0
- clue/plugin/helpers/__init__.py +0 -0
- clue/plugin/helpers/central_server.py +27 -0
- clue/plugin/helpers/email_render.py +228 -0
- clue/plugin/helpers/token.py +34 -0
- clue/plugin/helpers/trino.py +103 -0
- clue/plugin/interactive.py +270 -0
- clue/plugin/models.py +19 -0
- clue/plugin/utils.py +78 -0
- clue/remote/__init__.py +0 -0
- clue/remote/datatypes/__init__.py +130 -0
- clue/remote/datatypes/cache.py +62 -0
- clue/remote/datatypes/events.py +118 -0
- clue/remote/datatypes/hash.py +193 -0
- clue/remote/datatypes/queues/__init__.py +0 -0
- clue/remote/datatypes/queues/comms.py +62 -0
- clue/remote/datatypes/set.py +96 -0
- clue/remote/datatypes/user_quota_tracker.py +54 -0
- clue/security/__init__.py +211 -0
- clue/security/obo.py +95 -0
- clue/security/utils.py +34 -0
- clue/services/action_service.py +186 -0
- clue/services/auth_service.py +348 -0
- clue/services/config_service.py +38 -0
- clue/services/fetcher_service.py +203 -0
- clue/services/jwt_service.py +233 -0
- clue/services/lookup_service.py +786 -0
- clue/services/type_service.py +165 -0
- clue/services/user_service.py +152 -0
- clue_api-1.0.0.dev7.dist-info/METADATA +111 -0
- clue_api-1.0.0.dev7.dist-info/RECORD +91 -0
- clue_api-1.0.0.dev7.dist-info/WHEEL +4 -0
- clue_api-1.0.0.dev7.dist-info/entry_points.txt +8 -0
- clue_api-1.0.0.dev7.dist-info/licenses/LICENSE +11 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from flask import request
|
|
2
|
+
from pydantic import ValidationError
|
|
3
|
+
|
|
4
|
+
from clue.api import bad_request, make_subapi_blueprint, no_content, ok
|
|
5
|
+
from clue.common.logging import get_logger
|
|
6
|
+
from clue.common.swagger import generate_swagger_docs
|
|
7
|
+
from clue.config import config, get_redis
|
|
8
|
+
from clue.models.config import ExternalSource
|
|
9
|
+
from clue.remote.datatypes.set import Set
|
|
10
|
+
from clue.security import api_login
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__file__)
|
|
13
|
+
|
|
14
|
+
EXTERNAL_PLUGIN_SET = Set("plugin_set", host=get_redis())
|
|
15
|
+
|
|
16
|
+
SUB_API = "registration"
|
|
17
|
+
registration_api = make_subapi_blueprint(SUB_API, api_version=1)
|
|
18
|
+
registration_api._doc = "Register external plugins"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@generate_swagger_docs()
|
|
22
|
+
@registration_api.route("/register/", methods=["POST"])
|
|
23
|
+
@api_login()
|
|
24
|
+
def register_application(**kwargs):
|
|
25
|
+
"""Register the plugin given the provided data via REST API.
|
|
26
|
+
|
|
27
|
+
Variables:
|
|
28
|
+
None
|
|
29
|
+
|
|
30
|
+
Arguments:
|
|
31
|
+
None
|
|
32
|
+
|
|
33
|
+
API Call Examples:
|
|
34
|
+
/api/v1/registration/register/
|
|
35
|
+
|
|
36
|
+
Data Block:
|
|
37
|
+
[
|
|
38
|
+
{
|
|
39
|
+
"name": "test",
|
|
40
|
+
"classification": "TLP:CLEAR",
|
|
41
|
+
"max_classification": "TLP:CLEAR",
|
|
42
|
+
"url": "http://localhost:5008/",
|
|
43
|
+
"maintainer": "APA2B <apa2b-dl@cyber.gc.ca>",
|
|
44
|
+
"datahub_link": "http://example.com",
|
|
45
|
+
"documentation_link": "http://example.com"
|
|
46
|
+
},
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
Result Example:
|
|
50
|
+
{
|
|
51
|
+
"api_response": "test", # The response from the API
|
|
52
|
+
"api_error_message": "", # Error message returned by the API
|
|
53
|
+
"api_warning": [], # List of warnings from the API
|
|
54
|
+
"api_server_version": "1.0.0.dev0", # Version of the API server
|
|
55
|
+
"api_status_code": 200 # Status code returned by the API
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
if not request.json:
|
|
60
|
+
return bad_request(err="No data provided")
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
registration_request = ExternalSource(**request.json, built_in=False)
|
|
64
|
+
except ValidationError:
|
|
65
|
+
return bad_request(err="Request data could not be converted to an ExternalSource object")
|
|
66
|
+
|
|
67
|
+
config.api.external_sources.append(registration_request)
|
|
68
|
+
EXTERNAL_PLUGIN_SET.add(registration_request.model_dump(mode="json", exclude_none=True))
|
|
69
|
+
|
|
70
|
+
return ok(data=registration_request.name)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@generate_swagger_docs()
|
|
74
|
+
@registration_api.route("<plugin_id>", methods=["DELETE"])
|
|
75
|
+
@api_login()
|
|
76
|
+
def remove_application(plugin_id: str, **kwargs):
|
|
77
|
+
"""Remove the given plugin from the external_sources list via REST API.
|
|
78
|
+
|
|
79
|
+
Variables:
|
|
80
|
+
name => "test"
|
|
81
|
+
|
|
82
|
+
Optional Arguments:
|
|
83
|
+
None
|
|
84
|
+
|
|
85
|
+
API Call Examples:
|
|
86
|
+
/api/v1/registration/test
|
|
87
|
+
|
|
88
|
+
Result Example:
|
|
89
|
+
{
|
|
90
|
+
"response_status": "204 NO CONTENT" # HTTP status code
|
|
91
|
+
}
|
|
92
|
+
"""
|
|
93
|
+
source_to_remove = None
|
|
94
|
+
|
|
95
|
+
for source in config.api.external_sources:
|
|
96
|
+
if source.name == plugin_id and source.built_in is False:
|
|
97
|
+
source_to_remove = source
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
source_to_remove is not None
|
|
102
|
+
and source_to_remove.model_dump(mode="json", exclude_none=True) in EXTERNAL_PLUGIN_SET.members()
|
|
103
|
+
):
|
|
104
|
+
config.api.external_sources.remove(source_to_remove)
|
|
105
|
+
EXTERNAL_PLUGIN_SET.remove(source_to_remove.model_dump(mode="json", exclude_none=True))
|
|
106
|
+
logger.info(no_content(data=source_to_remove.name))
|
|
107
|
+
return no_content(data=source_to_remove.name)
|
|
108
|
+
|
|
109
|
+
return no_content(data=f"No plugin found with id: {plugin_id}")
|
clue/api/v1/static.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from flask import request
|
|
4
|
+
from flask_cors import CORS
|
|
5
|
+
|
|
6
|
+
from clue.api import make_subapi_blueprint, not_found, ok
|
|
7
|
+
from clue.common.swagger import generate_swagger_docs
|
|
8
|
+
from clue.config import config
|
|
9
|
+
from clue.security import api_login
|
|
10
|
+
|
|
11
|
+
SUB_API = "static"
|
|
12
|
+
static_api = make_subapi_blueprint(SUB_API, api_version=1)
|
|
13
|
+
static_api._doc = "Fetch static documentation"
|
|
14
|
+
|
|
15
|
+
CORS(static_api, origins=config.ui.cors_origins, supports_credentials=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@generate_swagger_docs(responses={200: "A markdown file containing documentation"})
|
|
19
|
+
@static_api.route("/docs", methods=["GET"])
|
|
20
|
+
@api_login()
|
|
21
|
+
def serve_documentation(**kwargs) -> dict[str, str]:
|
|
22
|
+
"""Returns all documentation or filtered documentation if given a url param of a file name or a path
|
|
23
|
+
|
|
24
|
+
Variables:
|
|
25
|
+
None
|
|
26
|
+
|
|
27
|
+
Arguments:
|
|
28
|
+
None
|
|
29
|
+
|
|
30
|
+
Result Example:
|
|
31
|
+
URL Link: /api/v1/static/docs?filter="howler"
|
|
32
|
+
|
|
33
|
+
{"howler-docs.md": "Markdown documentation of howler-docs.md"}
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
docs_filter = request.args.get("filter")
|
|
37
|
+
|
|
38
|
+
documentation_folder = Path.cwd() / "docs"
|
|
39
|
+
|
|
40
|
+
returned_files = {}
|
|
41
|
+
|
|
42
|
+
if docs_filter is None:
|
|
43
|
+
for file in documentation_folder.rglob("*"):
|
|
44
|
+
if file.is_file():
|
|
45
|
+
content = file.read_text(encoding="utf-8")
|
|
46
|
+
returned_files[file.name] = content
|
|
47
|
+
else:
|
|
48
|
+
for file in documentation_folder.rglob("*"):
|
|
49
|
+
if file.is_file() and docs_filter in file.name:
|
|
50
|
+
try:
|
|
51
|
+
content = file.read_text(encoding="utf-8")
|
|
52
|
+
returned_files[file.name] = content
|
|
53
|
+
except FileNotFoundError:
|
|
54
|
+
return not_found(err="The file was not found")
|
|
55
|
+
|
|
56
|
+
return ok(returned_files)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@generate_swagger_docs(responses={200: "A markdown file containing documentation"})
|
|
60
|
+
@static_api.route("/docs/<filename>", methods=["GET"])
|
|
61
|
+
@api_login()
|
|
62
|
+
def serve_documentation_file(filename: str, **kwargs) -> dict[str, str]:
|
|
63
|
+
"""Returns the specific file asked for within the route param
|
|
64
|
+
|
|
65
|
+
Variables:
|
|
66
|
+
filename (str): the specific file requested with an extension (i.e. *.md)
|
|
67
|
+
|
|
68
|
+
Arguments:
|
|
69
|
+
None
|
|
70
|
+
|
|
71
|
+
Result Example:
|
|
72
|
+
URL Link: /api/v1/static/docs/alfred-docs.md
|
|
73
|
+
|
|
74
|
+
{"markdown": "Markdown documentation of howler-docs.md"}
|
|
75
|
+
|
|
76
|
+
"""
|
|
77
|
+
documentation_folder = Path.cwd() / "documentation"
|
|
78
|
+
|
|
79
|
+
if "." not in filename:
|
|
80
|
+
# Assume it's markdown
|
|
81
|
+
filename = filename + ".md"
|
|
82
|
+
|
|
83
|
+
returned_file = {}
|
|
84
|
+
|
|
85
|
+
for file in documentation_folder.rglob("*"):
|
|
86
|
+
if file.is_file() and file.name == filename:
|
|
87
|
+
content = file.read_text(encoding="utf-8")
|
|
88
|
+
|
|
89
|
+
returned_file["markdown"] = content
|
|
90
|
+
|
|
91
|
+
if "markdown" not in returned_file:
|
|
92
|
+
return not_found(err="The file does not exist or is typed incorrectly.")
|
|
93
|
+
|
|
94
|
+
return ok(returned_file)
|
clue/app.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import elasticapm
|
|
7
|
+
from authlib.integrations.flask_client import OAuth
|
|
8
|
+
from elasticapm.contrib.flask import ElasticAPM
|
|
9
|
+
from flasgger import Swagger
|
|
10
|
+
from flask import Flask
|
|
11
|
+
from flask.logging import default_handler
|
|
12
|
+
from prometheus_client import make_wsgi_app
|
|
13
|
+
from werkzeug.middleware.dispatcher import DispatcherMiddleware
|
|
14
|
+
|
|
15
|
+
from clue.api.base import api
|
|
16
|
+
from clue.api.v1 import apiv1
|
|
17
|
+
from clue.api.v1.actions import actions_api
|
|
18
|
+
from clue.api.v1.auth import auth_api
|
|
19
|
+
from clue.api.v1.configs import configs_api
|
|
20
|
+
from clue.api.v1.fetchers import fetchers_api
|
|
21
|
+
from clue.api.v1.lookup import lookup_api
|
|
22
|
+
from clue.api.v1.registration import registration_api
|
|
23
|
+
from clue.api.v1.static import static_api
|
|
24
|
+
from clue.common.logging import get_logger
|
|
25
|
+
from clue.config import DEBUG, SECRET_KEY, cache, config
|
|
26
|
+
from clue.cronjobs import setup_jobs as setup_cron_jobs
|
|
27
|
+
from clue.error import errors
|
|
28
|
+
from clue.healthz import healthz
|
|
29
|
+
|
|
30
|
+
SESSION_COOKIE_SAMESITE = os.environ.get("BRL_SESSION_COOKIE_SAMESITE", None)
|
|
31
|
+
HSTS_MAX_AGE = os.environ.get("BRL_HSTS_MAX_AGE", None)
|
|
32
|
+
|
|
33
|
+
logger = get_logger(__file__)
|
|
34
|
+
|
|
35
|
+
##########################
|
|
36
|
+
# App settings
|
|
37
|
+
current_directory = os.path.dirname(__file__)
|
|
38
|
+
|
|
39
|
+
app = Flask("clue_api")
|
|
40
|
+
# Disable strict check on trailing slashes for endpoints
|
|
41
|
+
app.url_map.strict_slashes = False
|
|
42
|
+
app.config["JSON_SORT_KEYS"] = False
|
|
43
|
+
|
|
44
|
+
app.wsgi_app = DispatcherMiddleware(app.wsgi_app, {"/metrics": make_wsgi_app()}) # type: ignore[method-assign]
|
|
45
|
+
|
|
46
|
+
swagger_template = {
|
|
47
|
+
"info": {
|
|
48
|
+
"title": "Clue API",
|
|
49
|
+
"description": "Clue is designed to provide analysts with pertinent insights wherever they engage with "
|
|
50
|
+
"data. It serves as a single portal for all enrichments, offering pro-active enrichment capabilities and "
|
|
51
|
+
"on-demand execution and scaling. Clue also features advanced insight visualizations and reusable UI "
|
|
52
|
+
"components to enhance the user experience and streamline analytic workflows.",
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
swagger = Swagger(
|
|
56
|
+
app,
|
|
57
|
+
template=swagger_template,
|
|
58
|
+
config={
|
|
59
|
+
"headers": [],
|
|
60
|
+
"static_url_path": "/api/swagger_static",
|
|
61
|
+
"specs": [
|
|
62
|
+
{
|
|
63
|
+
"endpoint": "apispec_v1",
|
|
64
|
+
"route": "/api/apispec_v1.json",
|
|
65
|
+
"rule_filter": lambda rule: True, # all in
|
|
66
|
+
"model_filter": lambda tag: True, # all in
|
|
67
|
+
}
|
|
68
|
+
],
|
|
69
|
+
"specs_route": "/api/docs",
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
cache.init_app(app)
|
|
74
|
+
|
|
75
|
+
app.logger.setLevel(60) # This completely turns off the flask logger
|
|
76
|
+
|
|
77
|
+
ssl_context = None
|
|
78
|
+
logger.debug("Using flask secret key %s", re.sub(r"(.{6}).+(.{6})", r"\1...\2", SECRET_KEY))
|
|
79
|
+
app.config.update(SESSION_COOKIE_SECURE=True, SECRET_KEY=SECRET_KEY, PREFERRED_URL_SCHEME="https")
|
|
80
|
+
if SESSION_COOKIE_SAMESITE:
|
|
81
|
+
if SESSION_COOKIE_SAMESITE in ["Strict", "Lax"]:
|
|
82
|
+
app.config.update(SESSION_COOKIE_SAMESITE=SESSION_COOKIE_SAMESITE)
|
|
83
|
+
else:
|
|
84
|
+
raise ValueError("SESSION_COOKIE_SAMESITE must be set to 'Strict', 'Lax', or None")
|
|
85
|
+
|
|
86
|
+
app.register_blueprint(healthz)
|
|
87
|
+
app.register_blueprint(api)
|
|
88
|
+
app.register_blueprint(apiv1)
|
|
89
|
+
app.register_blueprint(errors)
|
|
90
|
+
app.register_blueprint(auth_api)
|
|
91
|
+
app.register_blueprint(actions_api)
|
|
92
|
+
app.register_blueprint(configs_api)
|
|
93
|
+
app.register_blueprint(fetchers_api)
|
|
94
|
+
app.register_blueprint(lookup_api)
|
|
95
|
+
app.register_blueprint(registration_api)
|
|
96
|
+
app.register_blueprint(static_api)
|
|
97
|
+
# Setup OAuth providers
|
|
98
|
+
if config.auth.oauth.enabled:
|
|
99
|
+
providers = []
|
|
100
|
+
for name, provider in config.auth.oauth.providers.items():
|
|
101
|
+
raw_provider: dict[str, Any] = provider.model_dump(mode="json", exclude_none=True)
|
|
102
|
+
|
|
103
|
+
# Set provider name
|
|
104
|
+
raw_provider["name"] = name
|
|
105
|
+
|
|
106
|
+
# Remove clue specific fields from oAuth config
|
|
107
|
+
raw_provider.pop("auto_create", None)
|
|
108
|
+
raw_provider.pop("auto_sync", None)
|
|
109
|
+
raw_provider.pop("user_get", None)
|
|
110
|
+
raw_provider.pop("auto_properties", None)
|
|
111
|
+
raw_provider.pop("uid_regex", None)
|
|
112
|
+
raw_provider.pop("uid_format", None)
|
|
113
|
+
raw_provider.pop("user_groups", None)
|
|
114
|
+
raw_provider.pop("user_groups_data_field", None)
|
|
115
|
+
raw_provider.pop("user_groups_name_field", None)
|
|
116
|
+
raw_provider.pop("app_provider", None)
|
|
117
|
+
|
|
118
|
+
# Add the provider to the list of providers
|
|
119
|
+
providers.append(raw_provider)
|
|
120
|
+
|
|
121
|
+
if providers:
|
|
122
|
+
oauth = OAuth()
|
|
123
|
+
for raw_provider in providers:
|
|
124
|
+
oauth.register(**raw_provider)
|
|
125
|
+
oauth.init_app(app)
|
|
126
|
+
|
|
127
|
+
if config.auth.allow_apikeys:
|
|
128
|
+
logger.debug(f"Allowing API Key use. Registered keys: {','.join(config.auth.apikeys.keys())}")
|
|
129
|
+
|
|
130
|
+
# Setup logging
|
|
131
|
+
app.logger.setLevel(logger.getEffectiveLevel())
|
|
132
|
+
app.logger.removeHandler(default_handler)
|
|
133
|
+
if logger.parent:
|
|
134
|
+
for ph in logger.parent.handlers:
|
|
135
|
+
app.logger.addHandler(ph)
|
|
136
|
+
|
|
137
|
+
# Setup APMs
|
|
138
|
+
if config.core.metrics.apm_server.server_url is not None:
|
|
139
|
+
app.logger.info(f"Exporting application metrics to: {config.core.metrics.apm_server.server_url}")
|
|
140
|
+
ElasticAPM(
|
|
141
|
+
app, client=elasticapm.Client(server_url=config.core.metrics.apm_server.server_url, service_name="enrichment")
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
wlog = logging.getLogger("werkzeug")
|
|
145
|
+
wlog.setLevel(logging.WARNING)
|
|
146
|
+
if logger.parent: # pragma: no cover
|
|
147
|
+
for h in logger.parent.handlers:
|
|
148
|
+
wlog.addHandler(h)
|
|
149
|
+
|
|
150
|
+
# setup Cronjob
|
|
151
|
+
setup_cron_jobs()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def main():
|
|
155
|
+
"""Runs the flask server"""
|
|
156
|
+
app.jinja_env.cache = {}
|
|
157
|
+
app.run(
|
|
158
|
+
host="0.0.0.0", # noqa: S104
|
|
159
|
+
debug=DEBUG,
|
|
160
|
+
port=int(os.getenv("FLASK_RUN_PORT", os.getenv("PORT", 5000))),
|
|
161
|
+
ssl_context=ssl_context,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
main()
|
clue/cache/__init__.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from hashlib import sha256
|
|
2
|
+
from typing import TYPE_CHECKING, Any, Literal, Self
|
|
3
|
+
|
|
4
|
+
from flask import Flask
|
|
5
|
+
from flask_caching import Cache as FlaskCache
|
|
6
|
+
from pydantic import TypeAdapter
|
|
7
|
+
|
|
8
|
+
from clue.common.logging import get_logger
|
|
9
|
+
from clue.config import get_redis
|
|
10
|
+
from clue.models.network import QueryEntry
|
|
11
|
+
from clue.remote.datatypes.hash import ExpiringHash
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from clue.plugin.utils import Params
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__file__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Cache:
|
|
20
|
+
"Caching wrapped for local/redis cache"
|
|
21
|
+
|
|
22
|
+
__type: Literal["redis"] | Literal["local"]
|
|
23
|
+
__local_cache: FlaskCache | None
|
|
24
|
+
__redis_cache: ExpiringHash | None
|
|
25
|
+
__app: Flask
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self: Self,
|
|
29
|
+
cache_name: str,
|
|
30
|
+
app: Flask,
|
|
31
|
+
type: Literal["redis"] | Literal["local"],
|
|
32
|
+
timeout: int = 5 * 60, # five minute timeout
|
|
33
|
+
local_cache_options: dict[str, Any] | None = None,
|
|
34
|
+
):
|
|
35
|
+
self.__app = app
|
|
36
|
+
self.__type = type
|
|
37
|
+
|
|
38
|
+
logger.debug("Enabling cache, type %s", self.__type)
|
|
39
|
+
if self.__type == "local":
|
|
40
|
+
self.__local_cache = FlaskCache(
|
|
41
|
+
self.__app,
|
|
42
|
+
config=(
|
|
43
|
+
local_cache_options
|
|
44
|
+
if local_cache_options is not None
|
|
45
|
+
else {"CACHE_TYPE": "SimpleCache", "CACHE_DEFAULT_TIMEOUT": timeout}
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
self.__redis_cache = ExpiringHash(cache_name, host=get_redis(), ttl=timeout)
|
|
50
|
+
|
|
51
|
+
def __generate_hash(self: Self, type_name: str, value: str, params: "Params") -> str:
|
|
52
|
+
"Generate a sha256 hash based on the selector"
|
|
53
|
+
hash_data = sha256(type_name.encode())
|
|
54
|
+
hash_data.update(value.encode())
|
|
55
|
+
|
|
56
|
+
hash_data.update(str(params.annotate).encode())
|
|
57
|
+
hash_data.update(str(params.raw).encode())
|
|
58
|
+
hash_data.update(str(params.limit).encode())
|
|
59
|
+
|
|
60
|
+
key = hash_data.hexdigest()
|
|
61
|
+
|
|
62
|
+
return key
|
|
63
|
+
|
|
64
|
+
def set(self: Self, type_name: str, value: str, params: "Params", data: list[QueryEntry]):
|
|
65
|
+
"Add the result of a given enrichment to the cache"
|
|
66
|
+
key = self.__generate_hash(type_name, value, params)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
serialized_data = TypeAdapter(list[QueryEntry]).dump_python(
|
|
70
|
+
data, mode="json", exclude_none=True, exclude_unset=True
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if self.__type == "local":
|
|
74
|
+
if self.__local_cache is None:
|
|
75
|
+
logger.warning("Local cache is None despite type being local")
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
self.__local_cache.set(key, serialized_data)
|
|
79
|
+
else:
|
|
80
|
+
if self.__redis_cache is None:
|
|
81
|
+
logger.warning("Redis cache is None despite type being redis")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
self.__redis_cache.set(key, serialized_data)
|
|
85
|
+
except Exception:
|
|
86
|
+
logger.exception("Error on retrieval")
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
def get(self: Self, type_name: str, value: str, params: "Params") -> list[QueryEntry] | None:
|
|
90
|
+
"Add the result of a given enrichment to the cache"
|
|
91
|
+
key = self.__generate_hash(type_name, value, params)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
if self.__type == "local":
|
|
95
|
+
if self.__local_cache is None:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
cached_result = self.__local_cache.get(key)
|
|
99
|
+
else:
|
|
100
|
+
if self.__redis_cache is None:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
cached_result = self.__redis_cache.get(key)
|
|
104
|
+
|
|
105
|
+
if not cached_result:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
if not isinstance(cached_result, list):
|
|
109
|
+
cached_result = [cached_result]
|
|
110
|
+
|
|
111
|
+
return TypeAdapter(list[QueryEntry]).validate_python(cached_result)
|
|
112
|
+
except Exception:
|
|
113
|
+
logger.exception("Error on cache retrieval")
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def delete(self: Self, type_name: str, value: str, params: "Params"):
|
|
117
|
+
"Remove data associated with a key from the cache"
|
|
118
|
+
key = self.__generate_hash(type_name, value, params)
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
if self.__type == "local":
|
|
122
|
+
if self.__local_cache:
|
|
123
|
+
self.__local_cache.delete(key)
|
|
124
|
+
else:
|
|
125
|
+
if self.__redis_cache:
|
|
126
|
+
self.__redis_cache.pop(key)
|
|
127
|
+
except Exception:
|
|
128
|
+
logger.exception("Error on cache deletion")
|
|
129
|
+
return None
|
clue/common/__init__.py
ADDED
|
File without changes
|