contentgrid-extension-helpers 0.0.1__py3-none-any.whl → 0.0.3__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.
- contentgrid_extension_helpers/__init__.py +3 -0
- contentgrid_extension_helpers/authentication/__init__.py +2 -2
- contentgrid_extension_helpers/authentication/user.py +28 -2
- contentgrid_extension_helpers/config.py +40 -0
- contentgrid_extension_helpers/dependencies/authentication/user.py +73 -0
- contentgrid_extension_helpers/dependencies/clients/contentgrid/__init__.py +3 -0
- contentgrid_extension_helpers/dependencies/clients/contentgrid/client_factory.py +71 -0
- contentgrid_extension_helpers/dependencies/clients/contentgrid/extension_flow_factory.py +85 -0
- contentgrid_extension_helpers/dependencies/clients/contentgrid/service_account_factory.py +87 -0
- contentgrid_extension_helpers/dependencies/sqlalch/__init__.py +0 -0
- contentgrid_extension_helpers/dependencies/sqlalch/db/__init__.py +14 -0
- contentgrid_extension_helpers/dependencies/sqlalch/db/base_factory.py +107 -0
- contentgrid_extension_helpers/dependencies/sqlalch/db/postgres.py +104 -0
- contentgrid_extension_helpers/dependencies/sqlalch/db/sqlite.py +43 -0
- contentgrid_extension_helpers/dependencies/sqlalch/repositories/__init__.py +1 -0
- contentgrid_extension_helpers/dependencies/sqlalch/repositories/base_repository.py +52 -0
- contentgrid_extension_helpers/exceptions.py +48 -0
- contentgrid_extension_helpers/logging/__init__.py +1 -1
- contentgrid_extension_helpers/logging/json_logging.py +2 -2
- contentgrid_extension_helpers/middleware/exception_middleware.py +124 -0
- contentgrid_extension_helpers/problem_response.py +45 -0
- contentgrid_extension_helpers/responses/__init__.py +0 -0
- contentgrid_extension_helpers/responses/hal.py +212 -0
- contentgrid_extension_helpers/structured_output/model_deny.py +45 -0
- {contentgrid_extension_helpers-0.0.1.dist-info → contentgrid_extension_helpers-0.0.3.dist-info}/METADATA +8 -2
- contentgrid_extension_helpers-0.0.3.dist-info/RECORD +30 -0
- {contentgrid_extension_helpers-0.0.1.dist-info → contentgrid_extension_helpers-0.0.3.dist-info}/WHEEL +1 -1
- contentgrid_extension_helpers-0.0.1.dist-info/RECORD +0 -11
- {contentgrid_extension_helpers-0.0.1.dist-info → contentgrid_extension_helpers-0.0.3.dist-info/licenses}/LICENSE +0 -0
- {contentgrid_extension_helpers-0.0.1.dist-info → contentgrid_extension_helpers-0.0.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from typing import Dict, Any, Optional
|
|
2
|
+
|
|
3
|
+
from pydantic import field_validator
|
|
4
|
+
from .base_factory import DatabaseConfig, DatabaseSessionFactory
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SQLiteConfig(DatabaseConfig):
|
|
8
|
+
sqlite_file_name: str = "database.db"
|
|
9
|
+
|
|
10
|
+
@field_validator("sqlite_file_name")
|
|
11
|
+
def validate_sqlite_file_name(cls, value: str) -> str:
|
|
12
|
+
if not value.endswith('.db'):
|
|
13
|
+
raise ValueError("SQLite file name must end with '.db'")
|
|
14
|
+
return value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SQLiteSessionFactory(DatabaseSessionFactory):
|
|
18
|
+
"""Factory class to create SQLite database connections."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, debug: Optional[bool]= None, sqlite_file_name : Optional[str] = None):
|
|
21
|
+
config_dict = {}
|
|
22
|
+
if debug is not None:
|
|
23
|
+
config_dict['debug'] = debug
|
|
24
|
+
if sqlite_file_name is not None:
|
|
25
|
+
config_dict['sqlite_file_name'] = sqlite_file_name
|
|
26
|
+
|
|
27
|
+
db_config = SQLiteConfig(**config_dict)
|
|
28
|
+
super().__init__(db_config)
|
|
29
|
+
|
|
30
|
+
def create_connection_string(self) -> str:
|
|
31
|
+
"""Create the SQLite connection string."""
|
|
32
|
+
config = self.db_config
|
|
33
|
+
if not isinstance(config, SQLiteConfig):
|
|
34
|
+
raise ValueError("SQLiteConfig is required for SQLiteSessionFactory")
|
|
35
|
+
return f"sqlite:///{config.sqlite_file_name}"
|
|
36
|
+
|
|
37
|
+
def create_connect_args(self) -> Dict[str, Any]:
|
|
38
|
+
"""Create SQLite connection arguments."""
|
|
39
|
+
return {"check_same_thread": False}
|
|
40
|
+
|
|
41
|
+
def create_engine_kwargs(self) -> Dict[str, Any]:
|
|
42
|
+
"""Create SQLite engine keyword arguments."""
|
|
43
|
+
return {} # No pool config for SQLite
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .base_repository import BaseRepository
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from typing import Dict, Generic, List, Type, TypeVar
|
|
2
|
+
from contentgrid_hal_client import NotFound
|
|
3
|
+
from sqlmodel import SQLModel, Session, select
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
T = TypeVar('T', bound=SQLModel)
|
|
7
|
+
CreateT = TypeVar('CreateT', bound=SQLModel)
|
|
8
|
+
UpdateT = TypeVar('UpdateT', bound=SQLModel)
|
|
9
|
+
|
|
10
|
+
class BaseRepository(Generic[T, CreateT, UpdateT]):
|
|
11
|
+
"""Generic base repository for database operations"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, session: Session, model_class: Type[T]):
|
|
14
|
+
self.session = session
|
|
15
|
+
self.model_class = model_class
|
|
16
|
+
|
|
17
|
+
def create(self, create_model: CreateT) -> T:
|
|
18
|
+
"""Create a new entity"""
|
|
19
|
+
db_entity = self.model_class.model_validate(create_model)
|
|
20
|
+
self.session.add(db_entity)
|
|
21
|
+
self.session.commit()
|
|
22
|
+
self.session.refresh(db_entity)
|
|
23
|
+
return db_entity
|
|
24
|
+
|
|
25
|
+
def get_by_id(self, entity_id: int) -> T:
|
|
26
|
+
"""Get entity by ID"""
|
|
27
|
+
entity = self.session.get(self.model_class, entity_id)
|
|
28
|
+
if not entity:
|
|
29
|
+
raise NotFound(f"{self.model_class.__name__} with id {entity_id} not found")
|
|
30
|
+
return entity
|
|
31
|
+
|
|
32
|
+
def get_all(self, offset: int = 0, limit: int = 100) -> List[T]:
|
|
33
|
+
# user filter has to be pre query for no error pagination
|
|
34
|
+
"""Get all entities with pagination"""
|
|
35
|
+
return self.session.exec(select(self.model_class).offset(offset).limit(limit)).all()
|
|
36
|
+
|
|
37
|
+
def update(self, entity_id: int, update_model: UpdateT) -> T:
|
|
38
|
+
"""Update entity by ID"""
|
|
39
|
+
db_entity = self.get_by_id(entity_id)
|
|
40
|
+
update_data = update_model.model_dump(exclude_unset=True)
|
|
41
|
+
db_entity.sqlmodel_update(update_data)
|
|
42
|
+
self.session.add(db_entity)
|
|
43
|
+
self.session.commit()
|
|
44
|
+
self.session.refresh(db_entity)
|
|
45
|
+
return db_entity
|
|
46
|
+
|
|
47
|
+
def delete(self, entity_id: int) -> Dict[str, bool]:
|
|
48
|
+
"""Delete entity by ID"""
|
|
49
|
+
db_entity = self.get_by_id(entity_id)
|
|
50
|
+
self.session.delete(db_entity)
|
|
51
|
+
self.session.commit()
|
|
52
|
+
return {"ok": True}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
|
|
2
|
+
class ExtensionHelperException(Exception):
|
|
3
|
+
"""Base class for all custom exceptions in the extension helpers library."""
|
|
4
|
+
def __init__(self, *args: object) -> None:
|
|
5
|
+
super().__init__(*args)
|
|
6
|
+
|
|
7
|
+
class LLMDenyException(ExtensionHelperException):
|
|
8
|
+
"""
|
|
9
|
+
Raised when the LLM denies a request based on its rules.
|
|
10
|
+
This is a base class for more specific denial reasons.
|
|
11
|
+
"""
|
|
12
|
+
def __init__(self, *args: object) -> None:
|
|
13
|
+
super().__init__(*args)
|
|
14
|
+
|
|
15
|
+
class NotRelatedError(LLMDenyException):
|
|
16
|
+
"""Raised when the input is unrelated to the required extraction."""
|
|
17
|
+
def __init__(self, *args: object) -> None:
|
|
18
|
+
super().__init__(*args)
|
|
19
|
+
|
|
20
|
+
class MissingInputError(LLMDenyException):
|
|
21
|
+
"""Raised when required input is missing or empty."""
|
|
22
|
+
def __init__(self, *args: object) -> None:
|
|
23
|
+
super().__init__(*args)
|
|
24
|
+
|
|
25
|
+
class MalformedInputError(LLMDenyException):
|
|
26
|
+
"""Raised when input is present but malformed or contains only strange characters."""
|
|
27
|
+
def __init__(self, *args: object) -> None:
|
|
28
|
+
super().__init__(*args)
|
|
29
|
+
|
|
30
|
+
class IllegalActivityError(LLMDenyException):
|
|
31
|
+
"""Raised when the input describes illegal activities."""
|
|
32
|
+
def __init__(self, *args: object) -> None:
|
|
33
|
+
super().__init__(*args)
|
|
34
|
+
|
|
35
|
+
class SensitiveInformationError(LLMDenyException):
|
|
36
|
+
"""Raised when the input contains sensitive information."""
|
|
37
|
+
def __init__(self, *args: object) -> None:
|
|
38
|
+
super().__init__(*args)
|
|
39
|
+
|
|
40
|
+
class SecurityError(LLMDenyException):
|
|
41
|
+
"""Raised when the input attempts to compromise system security."""
|
|
42
|
+
def __init__(self, *args: object) -> None:
|
|
43
|
+
super().__init__(*args)
|
|
44
|
+
|
|
45
|
+
class InjectionError(SecurityError):
|
|
46
|
+
"""Raised when the input is a prompt injection attack."""
|
|
47
|
+
def __init__(self, *args: object) -> None:
|
|
48
|
+
super().__init__(*args)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
from .json_logging import setup_json_logging, XenitJsonFormatter
|
|
1
|
+
from .json_logging import setup_json_logging, XenitJsonFormatter # noqa: F401
|
|
@@ -2,7 +2,7 @@ import datetime
|
|
|
2
2
|
import json
|
|
3
3
|
import logging
|
|
4
4
|
import time
|
|
5
|
-
from typing import
|
|
5
|
+
from typing import Optional, Any, Dict
|
|
6
6
|
|
|
7
7
|
class XenitJsonFormatter(logging.Formatter):
|
|
8
8
|
def __init__(
|
|
@@ -54,7 +54,7 @@ class XenitJsonFormatter(logging.Formatter):
|
|
|
54
54
|
|
|
55
55
|
def setup_json_logging(
|
|
56
56
|
component: str = "cg-extension",
|
|
57
|
-
additional_keys: dict[str,
|
|
57
|
+
additional_keys: dict[str,str] = {},
|
|
58
58
|
log_level: int = logging.DEBUG
|
|
59
59
|
) -> logging.Logger:
|
|
60
60
|
"""
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from fastapi import Request, status
|
|
2
|
+
import logging
|
|
3
|
+
from contentgrid_extension_helpers.problem_response import ProblemResponse
|
|
4
|
+
from contentgrid_hal_client.exceptions import (
|
|
5
|
+
NotFound,
|
|
6
|
+
Unauthorized,
|
|
7
|
+
BadRequest,
|
|
8
|
+
IncorrectAttributeType,
|
|
9
|
+
NonExistantAttribute,
|
|
10
|
+
MissingRequiredAttribute,
|
|
11
|
+
MissingHALTemplate,
|
|
12
|
+
)
|
|
13
|
+
from requests.exceptions import HTTPError
|
|
14
|
+
from contentgrid_extension_helpers.exceptions import LLMDenyException
|
|
15
|
+
|
|
16
|
+
async def catch_exceptions_middleware(request: Request, call_next, problem_base_url: str = "https://problems.contentgrid.test"):
|
|
17
|
+
"""
|
|
18
|
+
Catches exceptions and returns ProblemResponse objects.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
request: The incoming request.
|
|
22
|
+
call_next: The next middleware or route handler.
|
|
23
|
+
problem_base_url: The base URL for problem type URIs.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def format_type(type_name: str) -> str:
|
|
27
|
+
"""Formats problem type URIs."""
|
|
28
|
+
return f"{problem_base_url}/{type_name}"
|
|
29
|
+
|
|
30
|
+
cors_headers = {
|
|
31
|
+
"Access-Control-Allow-Origin": "*",
|
|
32
|
+
"Access-Control-Allow-Methods": "*",
|
|
33
|
+
"Access-Control-Allow-Headers": "*, Authorization",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
return await call_next(request)
|
|
38
|
+
except LLMDenyException as e:
|
|
39
|
+
logging.error(f"LLM denied request: {e}")
|
|
40
|
+
return ProblemResponse(
|
|
41
|
+
title="Request Denied",
|
|
42
|
+
problem_type=format_type("request-denied"),
|
|
43
|
+
detail=str(e),
|
|
44
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
45
|
+
headers=cors_headers,
|
|
46
|
+
)
|
|
47
|
+
except NotFound:
|
|
48
|
+
return ProblemResponse(
|
|
49
|
+
title="Not found",
|
|
50
|
+
problem_type=format_type("not-found"),
|
|
51
|
+
detail="Resource not found",
|
|
52
|
+
status=status.HTTP_404_NOT_FOUND,
|
|
53
|
+
headers=cors_headers,
|
|
54
|
+
)
|
|
55
|
+
except Unauthorized:
|
|
56
|
+
return ProblemResponse(
|
|
57
|
+
title="Unauthorized",
|
|
58
|
+
problem_type=format_type("unauthorized"),
|
|
59
|
+
detail="user is not authorized for requested resource",
|
|
60
|
+
status=status.HTTP_401_UNAUTHORIZED,
|
|
61
|
+
headers=cors_headers,
|
|
62
|
+
)
|
|
63
|
+
except BadRequest:
|
|
64
|
+
return ProblemResponse(
|
|
65
|
+
title="Bad Request",
|
|
66
|
+
problem_type=format_type("bad-request"),
|
|
67
|
+
detail="The request was malformed or invalid.",
|
|
68
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
69
|
+
headers=cors_headers,
|
|
70
|
+
)
|
|
71
|
+
except IncorrectAttributeType as e:
|
|
72
|
+
return ProblemResponse(
|
|
73
|
+
title="Incorrect Attribute Type",
|
|
74
|
+
problem_type=format_type("incorrect-attribute-type"),
|
|
75
|
+
detail=str(e),
|
|
76
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
77
|
+
headers=cors_headers,
|
|
78
|
+
)
|
|
79
|
+
except NonExistantAttribute as e:
|
|
80
|
+
return ProblemResponse(
|
|
81
|
+
title="Non-Existent Attribute",
|
|
82
|
+
problem_type=format_type("non-existent-attribute"),
|
|
83
|
+
detail=str(e),
|
|
84
|
+
status=status.HTTP_404_NOT_FOUND,
|
|
85
|
+
headers=cors_headers,
|
|
86
|
+
)
|
|
87
|
+
except MissingRequiredAttribute as e:
|
|
88
|
+
return ProblemResponse(
|
|
89
|
+
title="Missing Required Attribute",
|
|
90
|
+
problem_type=format_type("missing-required-attribute"),
|
|
91
|
+
detail=str(e),
|
|
92
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
93
|
+
headers=cors_headers,
|
|
94
|
+
)
|
|
95
|
+
except MissingHALTemplate as e:
|
|
96
|
+
return ProblemResponse(
|
|
97
|
+
title="Missing HAL Template",
|
|
98
|
+
problem_type=format_type("missing-hal-template"),
|
|
99
|
+
detail=str(e),
|
|
100
|
+
status=status.HTTP_404_NOT_FOUND,
|
|
101
|
+
headers=cors_headers,
|
|
102
|
+
)
|
|
103
|
+
except HTTPError as e:
|
|
104
|
+
logging.exception(f"HTTP Error: {str(e)}", exc_info=True)
|
|
105
|
+
return ProblemResponse(
|
|
106
|
+
title="HTTP Error",
|
|
107
|
+
problem_type=format_type("http-error"),
|
|
108
|
+
detail=f"An HTTP error occurred: {str(e)}",
|
|
109
|
+
status=e.response.status_code if e.response else status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
110
|
+
headers=cors_headers,
|
|
111
|
+
)
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logging.exception(
|
|
114
|
+
f"Untyped exception caught in backend request...: {str(e)}",
|
|
115
|
+
exc_info=True,
|
|
116
|
+
stack_info=True,
|
|
117
|
+
)
|
|
118
|
+
return ProblemResponse(
|
|
119
|
+
title="Internal server error",
|
|
120
|
+
problem_type=format_type("unknown"),
|
|
121
|
+
detail=f"An unexpected error occurred: {str(e)}",
|
|
122
|
+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
123
|
+
headers=cors_headers,
|
|
124
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from typing import Any, Dict, Mapping
|
|
2
|
+
from fastapi.responses import JSONResponse
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_problem_dict(title: str | None, problem_type: str | None, status: int | None, detail: str | None, instance: str | None, **kwargs) -> Dict[str, Any]:
|
|
6
|
+
result: Dict[str, Any] = dict()
|
|
7
|
+
if title is not None:
|
|
8
|
+
result["title"] = title
|
|
9
|
+
if problem_type is not None:
|
|
10
|
+
result["type"] = problem_type
|
|
11
|
+
if status is not None:
|
|
12
|
+
result["status"] = status
|
|
13
|
+
if detail is not None:
|
|
14
|
+
result["detail"] = detail
|
|
15
|
+
if instance is not None:
|
|
16
|
+
result["instance"] = instance
|
|
17
|
+
|
|
18
|
+
for key, value in kwargs.items():
|
|
19
|
+
if key == "type":
|
|
20
|
+
raise ValueError(f"{key} is a reserved property name!")
|
|
21
|
+
result[key] = value
|
|
22
|
+
|
|
23
|
+
return result
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ProblemResponse(JSONResponse):
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
title: str | None = None,
|
|
30
|
+
problem_type: str | None = "about:blank", # not 'type' because it is a built-in function
|
|
31
|
+
status: int = 400,
|
|
32
|
+
detail: str | None = None,
|
|
33
|
+
instance: str | None = None,
|
|
34
|
+
headers: Mapping[str, str] | None = None,
|
|
35
|
+
media_type: str | None = "application/problem+json",
|
|
36
|
+
background: Any | None = None,
|
|
37
|
+
**kwargs
|
|
38
|
+
):
|
|
39
|
+
super().__init__(
|
|
40
|
+
get_problem_dict(title, problem_type, status, detail, instance, **kwargs),
|
|
41
|
+
status_code=status,
|
|
42
|
+
headers=headers,
|
|
43
|
+
media_type=media_type,
|
|
44
|
+
background=background
|
|
45
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from urllib.parse import urlencode
|
|
2
|
+
from fastapi import FastAPI
|
|
3
|
+
from fastapi.routing import APIRoute
|
|
4
|
+
from pydantic import BaseModel, Field, field_serializer
|
|
5
|
+
from fastapi._compat import ModelField
|
|
6
|
+
from typing import Dict, Optional, Tuple, Type, Any, Union, List, Self, TypeVar, Generic, Callable, cast
|
|
7
|
+
import logging
|
|
8
|
+
from contentgrid_hal_client.hal import HALShape, HALLink
|
|
9
|
+
from contentgrid_hal_client.hal_forms import HALFormsTemplate, HALFormsMethod, HALFormsPropertyType, HALFormsProperty
|
|
10
|
+
import uri_template
|
|
11
|
+
|
|
12
|
+
def get_route_from_app(app: FastAPI, endpoint_function: str) -> APIRoute:
|
|
13
|
+
for route in app.routes:
|
|
14
|
+
if isinstance(route, APIRoute) and route.name == endpoint_function:
|
|
15
|
+
return route
|
|
16
|
+
else:
|
|
17
|
+
error_message = f"No route found for endpoint {endpoint_function}"
|
|
18
|
+
raise ValueError(error_message)
|
|
19
|
+
|
|
20
|
+
def _add_params(url : str, params: Optional[Dict[str, str]] = None) -> str:
|
|
21
|
+
if params:
|
|
22
|
+
url_postfix = "?" + urlencode(params)
|
|
23
|
+
url += url_postfix
|
|
24
|
+
return url
|
|
25
|
+
|
|
26
|
+
def get_body_from_route(route : APIRoute) -> Tuple[Optional[BaseModel], dict]:
|
|
27
|
+
required_body = route.body_field
|
|
28
|
+
if not required_body:
|
|
29
|
+
return None, {}
|
|
30
|
+
pydantic_body, default_data = get_pydantic_base_model_from_model_field(required_body)
|
|
31
|
+
return pydantic_body, default_data
|
|
32
|
+
|
|
33
|
+
def get_pydantic_base_model_from_model_field(model_field : ModelField) -> Tuple[BaseModel, dict]:
|
|
34
|
+
default_data = model_field._type_adapter.get_default_value() or {}
|
|
35
|
+
pydantic_body = cast(BaseModel, model_field._type_adapter._type)
|
|
36
|
+
return pydantic_body, default_data
|
|
37
|
+
|
|
38
|
+
def extract_hal_forms_properties_from_pydantic_base_model(
|
|
39
|
+
pydantic_base_model : BaseModel,
|
|
40
|
+
default_data : dict = {}
|
|
41
|
+
) -> List[HALFormsProperty]:
|
|
42
|
+
properties : List[HALFormsProperty] = []
|
|
43
|
+
for field_name, field_info in pydantic_base_model.model_fields.items():
|
|
44
|
+
# Determine property type based on field type
|
|
45
|
+
property_type = HALFormsPropertyType.text
|
|
46
|
+
|
|
47
|
+
# Convert field type to HALFormsPropertyType
|
|
48
|
+
if field_info.annotation is int or field_info.annotation is float:
|
|
49
|
+
property_type = HALFormsPropertyType.number
|
|
50
|
+
elif field_info.annotation is bool:
|
|
51
|
+
property_type = HALFormsPropertyType.checkbox
|
|
52
|
+
|
|
53
|
+
# Get default value for this field if available
|
|
54
|
+
field_default = default_data.get(field_name) if default_data else None
|
|
55
|
+
|
|
56
|
+
# Create HALFormsProperty
|
|
57
|
+
field_property = HALFormsProperty(
|
|
58
|
+
name=field_name,
|
|
59
|
+
prompt=field_info.description or field_name,
|
|
60
|
+
required=field_info.is_required(),
|
|
61
|
+
type=property_type,
|
|
62
|
+
value=field_default
|
|
63
|
+
)
|
|
64
|
+
properties.append(field_property)
|
|
65
|
+
return properties
|
|
66
|
+
|
|
67
|
+
class LinkForType(BaseModel):
|
|
68
|
+
endpoint_function_name: str
|
|
69
|
+
templated: bool = False
|
|
70
|
+
path_params: Union[dict[str, str], Callable[["FastAPIHALResponse"], dict[str, str]]] = Field(default_factory=dict)
|
|
71
|
+
params: Union[dict[str, Union[str, int, float]], Callable[["FastAPIHALResponse"], dict[str, Union[str, int, float]]]] = Field(default_factory=dict)
|
|
72
|
+
condition: Union[Callable[["FastAPIHALResponse"], bool], bool] = True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class HALLinkFor(LinkForType):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
class HALTemplateFor(LinkForType):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
HALLinks = dict[str, Union[HALLink, HALLinkFor]]
|
|
83
|
+
HALTemplates = dict[str, Union[HALFormsTemplate, HALTemplateFor]]
|
|
84
|
+
|
|
85
|
+
# Type variable for generic embedded resources - must be a subclass of FastAPIHALResponse
|
|
86
|
+
T = TypeVar('T', bound='FastAPIHALResponse')
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class FastAPIHALResponse(HALShape):
|
|
90
|
+
links: dict[str, Union[HALLink, HALLinkFor]] = Field(alias="_links", exclude=False, default_factory=dict)
|
|
91
|
+
templates: dict[str, Union[HALFormsTemplate, HALTemplateFor]] | None = Field(default=None, alias="_templates")
|
|
92
|
+
|
|
93
|
+
def __expand_link(self, link : HALLinkFor | HALLink) -> HALLink | None:
|
|
94
|
+
"""
|
|
95
|
+
Expand the links based on server url and path and params.
|
|
96
|
+
This method should be called after the class is initialized.
|
|
97
|
+
Returns None if the link should be excluded based on condition.
|
|
98
|
+
"""
|
|
99
|
+
if not hasattr(self.__class__, '_app'):
|
|
100
|
+
raise ValueError("App not initialized. Call init_app() before using this method.")
|
|
101
|
+
|
|
102
|
+
if not isinstance(link, HALLinkFor):
|
|
103
|
+
return link
|
|
104
|
+
|
|
105
|
+
# Check condition - if False, exclude the link
|
|
106
|
+
if isinstance(link.condition, bool):
|
|
107
|
+
if not link.condition:
|
|
108
|
+
return None
|
|
109
|
+
elif callable(link.condition):
|
|
110
|
+
if not link.condition(self):
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
if hasattr(self.__class__, '_app') and self.__class__._app:
|
|
114
|
+
route = get_route_from_app(self.__class__._app, link.endpoint_function_name)
|
|
115
|
+
|
|
116
|
+
if hasattr(self.__class__, '_server_url'):
|
|
117
|
+
uri = f"{self.__class__._server_url}{route.path}"
|
|
118
|
+
else:
|
|
119
|
+
uri = route.path
|
|
120
|
+
|
|
121
|
+
expanded_link = HALLink(uri=uri, templated=link.templated)
|
|
122
|
+
|
|
123
|
+
if link.path_params:
|
|
124
|
+
# Handle callable params
|
|
125
|
+
if callable(link.path_params):
|
|
126
|
+
resolved_path_params = link.path_params(self)
|
|
127
|
+
else:
|
|
128
|
+
resolved_path_params = link.path_params
|
|
129
|
+
expanded_link.uri = uri_template.URITemplate(expanded_link.uri).expand(**resolved_path_params)
|
|
130
|
+
|
|
131
|
+
if link.params:
|
|
132
|
+
# Handle callable params
|
|
133
|
+
if callable(link.params):
|
|
134
|
+
resolved_params = link.params(self)
|
|
135
|
+
else:
|
|
136
|
+
resolved_params = link.params
|
|
137
|
+
expanded_link.uri = _add_params(expanded_link.uri, resolved_params)
|
|
138
|
+
|
|
139
|
+
return expanded_link
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@field_serializer('links')
|
|
143
|
+
def ser_links(self, value: dict[str, Union[HALLink, HALLinkFor]]) -> dict[str, HALLink]:
|
|
144
|
+
expanded_links = {}
|
|
145
|
+
for key, link_value in value.items():
|
|
146
|
+
expanded_link = self.__expand_link(link_value)
|
|
147
|
+
if expanded_link is not None: # Only include if condition is met
|
|
148
|
+
expanded_links[key] = expanded_link
|
|
149
|
+
return expanded_links
|
|
150
|
+
|
|
151
|
+
@field_serializer('templates')
|
|
152
|
+
def ser_templates(self, value: dict[str, Union[HALFormsTemplate, HALTemplateFor]] | None) -> dict[str, HALFormsTemplate]:
|
|
153
|
+
expanded_templates = {}
|
|
154
|
+
if value is None:
|
|
155
|
+
return expanded_templates
|
|
156
|
+
for key, template_value in value.items():
|
|
157
|
+
if isinstance(template_value, HALTemplateFor) and hasattr(self.__class__, '_app') and self.__class__._app:
|
|
158
|
+
try:
|
|
159
|
+
hallink = self.__expand_link(HALLinkFor(
|
|
160
|
+
**template_value.model_dump()
|
|
161
|
+
))
|
|
162
|
+
|
|
163
|
+
if hallink is None:
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
uri = hallink.uri
|
|
167
|
+
route = get_route_from_app(self.__class__._app, template_value.endpoint_function_name)
|
|
168
|
+
body_model, default_data = get_body_from_route(route=route)
|
|
169
|
+
if body_model is None:
|
|
170
|
+
properties = []
|
|
171
|
+
else:
|
|
172
|
+
properties = extract_hal_forms_properties_from_pydantic_base_model(pydantic_base_model=body_model, default_data=default_data)
|
|
173
|
+
|
|
174
|
+
#TODO what should we do when there are multiple methods for the same function/endpoint?
|
|
175
|
+
expanded_templates[key] = HALFormsTemplate(
|
|
176
|
+
title=route.description if hasattr(route, 'description') and route.description else None,
|
|
177
|
+
method=HALFormsMethod(list(route.methods)[0]) if hasattr(route, 'methods') and route.methods else HALFormsMethod.GET,
|
|
178
|
+
target=uri,
|
|
179
|
+
properties=properties
|
|
180
|
+
)
|
|
181
|
+
except ValueError:
|
|
182
|
+
logging.error(f"{self.__class__} hal template expansion failed: Route not found for template endpoint: {template_value.endpoint_function_name}")
|
|
183
|
+
continue
|
|
184
|
+
else:
|
|
185
|
+
expanded_templates[key] = template_value
|
|
186
|
+
return expanded_templates
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@classmethod
|
|
190
|
+
def init_app(cls: Type[Self], app: Any) -> None:
|
|
191
|
+
"""
|
|
192
|
+
Bind a FastAPI app to other HyperModel base class.
|
|
193
|
+
This allows HyperModel to convert endpoint function names into
|
|
194
|
+
working URLs relative to the application root.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
app (FastAPI): Application to generate URLs from
|
|
198
|
+
"""
|
|
199
|
+
cls._app = app
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def add_server_url(cls: Type[Self], server_url: str) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Set the server URL for generating absolute URLs.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
server_url (str): The base URL of the server.
|
|
208
|
+
"""
|
|
209
|
+
cls._server_url = server_url
|
|
210
|
+
|
|
211
|
+
class FastAPIHALCollection(FastAPIHALResponse, Generic[T]):
|
|
212
|
+
embedded: dict[str, List[T]] | None = Field(default=None, alias="_embedded", description="Embedded resources")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
|
|
4
|
+
from contentgrid_extension_helpers import exceptions
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DenyReason(Enum):
|
|
8
|
+
NOT_RELATED = "not_related"
|
|
9
|
+
MISSING_INPUT = "missing_input"
|
|
10
|
+
MALFORMED_INPUT = "malformed_input"
|
|
11
|
+
ILLEGAL_ACTIVITY = "illegal_activity"
|
|
12
|
+
SENSITIVE_INFORMATION = "sensitive_information"
|
|
13
|
+
SECURITY_ERROR = "security_error"
|
|
14
|
+
INJECTION_ATTEMPT = "injection_attempt"
|
|
15
|
+
|
|
16
|
+
def to_exception(self) -> type[exceptions.LLMDenyException]:
|
|
17
|
+
"""Maps a DenyReason to its corresponding exception class."""
|
|
18
|
+
match self:
|
|
19
|
+
case DenyReason.NOT_RELATED:
|
|
20
|
+
return exceptions.NotRelatedError
|
|
21
|
+
case DenyReason.MISSING_INPUT:
|
|
22
|
+
return exceptions.MissingInputError
|
|
23
|
+
case DenyReason.MALFORMED_INPUT:
|
|
24
|
+
return exceptions.MalformedInputError
|
|
25
|
+
case DenyReason.ILLEGAL_ACTIVITY:
|
|
26
|
+
return exceptions.IllegalActivityError
|
|
27
|
+
case DenyReason.SENSITIVE_INFORMATION:
|
|
28
|
+
return exceptions.SensitiveInformationError
|
|
29
|
+
case DenyReason.SECURITY_ERROR:
|
|
30
|
+
return exceptions.SecurityError
|
|
31
|
+
case DenyReason.INJECTION_ATTEMPT:
|
|
32
|
+
return exceptions.InjectionError
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ModelDeny(BaseModel):
|
|
36
|
+
deny_type: DenyReason = Field(description="Type of denial")
|
|
37
|
+
reason: str = Field(
|
|
38
|
+
description="Reason for denying the request. Should be a short sentence that can be shown to the user. Not longer than two lines"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def __init__(self, **data):
|
|
42
|
+
super().__init__(**data)
|
|
43
|
+
# Raise the corresponding exception immediately
|
|
44
|
+
exception_class = self.deny_type.to_exception()
|
|
45
|
+
raise exception_class(self.reason)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: contentgrid-extension-helpers
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.3
|
|
4
4
|
Summary: Helper functions for contentgrid extensions.
|
|
5
5
|
Author-email: Ranec Belpaire <ranec.belpaire@xenit.eu>
|
|
6
6
|
License: Copyright 2024 Xenit Solutions
|
|
@@ -26,6 +26,12 @@ Requires-Dist: requests<3,>=2.20.0
|
|
|
26
26
|
Requires-Dist: uri-template<2
|
|
27
27
|
Requires-Dist: fastapi>=0.111
|
|
28
28
|
Requires-Dist: PyJWT>2
|
|
29
|
+
Requires-Dist: cryptography>45
|
|
30
|
+
Requires-Dist: contentgrid_hal_client>=0.1
|
|
31
|
+
Requires-Dist: contentgrid_application_client>=0.1
|
|
32
|
+
Requires-Dist: contentgrid_management_client>=0.1
|
|
33
|
+
Requires-Dist: pydantic<3,>=2
|
|
34
|
+
Dynamic: license-file
|
|
29
35
|
|
|
30
36
|
### ContentGrid-Extension-Helpers
|
|
31
37
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
contentgrid_extension_helpers/__init__.py,sha256=Mw64JI29DW5HOzmL1UA8grdyrOqAQeHlllR_zxjagy8,380
|
|
2
|
+
contentgrid_extension_helpers/config.py,sha256=V6IUE5bRyg0k5Gzbq8YdB2k_0OeEzHOI-_h4gaQJiPQ,1401
|
|
3
|
+
contentgrid_extension_helpers/exceptions.py,sha256=GJOIwDC_51pyhiPaq6wRrkmS3X1aU_PQoEE4sPTgopQ,1812
|
|
4
|
+
contentgrid_extension_helpers/problem_response.py,sha256=v2z_hx92pHWyqMZfnJDi6TaGVIeoGh2AXVr1F9VA00w,1542
|
|
5
|
+
contentgrid_extension_helpers/authentication/__init__.py,sha256=XnupVcladC7H4LudetZ46da9IP69pWKhtRJvikhmIKM,200
|
|
6
|
+
contentgrid_extension_helpers/authentication/oidc.py,sha256=14XEmp_WWDVygb3oBKB9S29UlgJV8wnm_1lw36U4xxc,9820
|
|
7
|
+
contentgrid_extension_helpers/authentication/user.py,sha256=EwUjIczs3oFiM0TQK-eLixVNgeou4OY0zTwszFURmrc,1540
|
|
8
|
+
contentgrid_extension_helpers/dependencies/authentication/user.py,sha256=c3Xgg3J4O_TqfdCIETg0VtNF1NUrzaB-gxCJaojrbv4,2817
|
|
9
|
+
contentgrid_extension_helpers/dependencies/clients/contentgrid/__init__.py,sha256=J43Bq-VOCr3OZQA8QzofQXwhDbB2jI4qEPbSc6JI_oI,240
|
|
10
|
+
contentgrid_extension_helpers/dependencies/clients/contentgrid/client_factory.py,sha256=7WL39-tsHZFve_35drrmwb2NczUxvLMiQQvXsdBguA4,2541
|
|
11
|
+
contentgrid_extension_helpers/dependencies/clients/contentgrid/extension_flow_factory.py,sha256=AHgxq3WttAsW4BqbMrgo77BmX94OMJz_Yb1TrjyWcOs,4111
|
|
12
|
+
contentgrid_extension_helpers/dependencies/clients/contentgrid/service_account_factory.py,sha256=BdUZxK2gU8TFZNWdkufm_73uKAkboyJd08-000kfwLc,3932
|
|
13
|
+
contentgrid_extension_helpers/dependencies/sqlalch/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
contentgrid_extension_helpers/dependencies/sqlalch/db/__init__.py,sha256=7PntPSCwinwSk2il3_LWoSEHnCmx4bQvYLHPC7WJbDw,414
|
|
15
|
+
contentgrid_extension_helpers/dependencies/sqlalch/db/base_factory.py,sha256=vXFQzot1aR__P1ZyMa7VE_JE1sixJjNkKlZBDppKowE,4263
|
|
16
|
+
contentgrid_extension_helpers/dependencies/sqlalch/db/postgres.py,sha256=X2kWg3EBTNcXhQQxuym4pW0GqmZiLxQFi4tvZqiwl7o,4563
|
|
17
|
+
contentgrid_extension_helpers/dependencies/sqlalch/db/sqlite.py,sha256=2FgAcLs8SqMWCUqlOa-rv_JWGLPVhDVt_eTiwWqWdZA,1592
|
|
18
|
+
contentgrid_extension_helpers/dependencies/sqlalch/repositories/__init__.py,sha256=ApJOGKoqnxLfdCqtnkQoOykPCYMnUQhtX29MyD5nOng,43
|
|
19
|
+
contentgrid_extension_helpers/dependencies/sqlalch/repositories/base_repository.py,sha256=PWN-V_YzYwLxRnZWSIfGyVxi8r9lq3wWObOi0cYcFRg,1975
|
|
20
|
+
contentgrid_extension_helpers/logging/__init__.py,sha256=15tz-g0fLdBjJto8kWcjCYhfXCJg0qrwSXVuXRH9i3Q,77
|
|
21
|
+
contentgrid_extension_helpers/logging/json_logging.py,sha256=NrzoBfEUAwQT7mmCHK0GoFJv5t9rrgjomDTXzZ-vgFI,3112
|
|
22
|
+
contentgrid_extension_helpers/middleware/exception_middleware.py,sha256=6mP7IQ7vPZ6-1_QESnAPZNQ5vA0MTTTzpovdszGp964,4467
|
|
23
|
+
contentgrid_extension_helpers/responses/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
24
|
+
contentgrid_extension_helpers/responses/hal.py,sha256=g7_lqBEEmSMYEqp0ZVTqT8xfMQXlT0e4_5_a7WcS1Qs,9234
|
|
25
|
+
contentgrid_extension_helpers/structured_output/model_deny.py,sha256=n2sEls0kyhvL8hHUKeo3_JQ_ZssyknIbIoDAOUvMVxc,1748
|
|
26
|
+
contentgrid_extension_helpers-0.0.3.dist-info/licenses/LICENSE,sha256=tk6n-p8lEmzLJg-O4052CkMgfUtt1q2Zoh1QLAyL7S8,555
|
|
27
|
+
contentgrid_extension_helpers-0.0.3.dist-info/METADATA,sha256=Wfpq2OD1UzL268ZxJEJ_9GotNg-qZU_5WkSvXU8r-oU,1574
|
|
28
|
+
contentgrid_extension_helpers-0.0.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
29
|
+
contentgrid_extension_helpers-0.0.3.dist-info/top_level.txt,sha256=yJGGofrNVsl5psVGO0vLFHO1610ob88GtB9zpvS8iIk,30
|
|
30
|
+
contentgrid_extension_helpers-0.0.3.dist-info/RECORD,,
|