src.api.http_adapter
HTTP REST API adapter
1"""HTTP REST API adapter""" 2 3import json 4from dataclasses import dataclass 5from typing import override 6 7from flask import Flask, Request, request 8from flask_cors import CORS 9from prometheus_flask_exporter import ( # pyright: ignore[reportMissingTypeStubs] PrometheusMetrics has no typing 10 PrometheusMetrics, 11) 12 13from src.config.config_port import ConfigRepoPort 14from src.offers.offer_service import OfferService, PermissionDeniedError 15 16from .api_port import ApiPort 17 18 19class MissingTokenError(Exception): 20 """Raised when the Authorization header is absent or contains no token.""" 21 22 23@dataclass 24class CreateOfferBody: 25 """Parsed request body for the create offer endpoint.""" 26 27 award_id: str 28 29 30@dataclass 31class Proof: 32 """Proof object for credential request.""" 33 34 proof_type: str 35 jwt: str 36 37 38@dataclass 39class CredentialRequestBody: 40 """Parsed request body for the credential endpoint.""" 41 42 format: str 43 credential_configuration_id: str 44 proof: Proof 45 issuer_state: str 46 47 48class HttpApiAdapter(ApiPort): 49 """HTTP REST API adapter""" 50 51 flask_app: Flask 52 offer_service: OfferService 53 config: ConfigRepoPort 54 55 def __init__( 56 self, 57 config: ConfigRepoPort, 58 offer_service: OfferService, 59 ): 60 """Initialise the adapter. 61 62 Args: 63 config: Application configuration. 64 metadata_service: Service for credential issuer metadata. 65 offer_service: Service for creating credential offers. 66 credential_service: Service for requesting credentials. 67 """ 68 self.offer_service = offer_service 69 self.config = config 70 self.flask_app = self._flask_app() 71 72 def _flask_app(self) -> Flask: 73 app = Flask("HttpApi") 74 75 # Configure CORS 76 allowed_origins = self._parse_allowed_cors_domains() 77 _ = CORS(app, resources={r"/*": {"origins": allowed_origins}}) 78 79 # Metrics endpoint is only relevant to HttpAdapter 80 # no need for service/domain models 81 metrics: PrometheusMetrics = PrometheusMetrics(app) 82 _ = metrics.info("app_info", "Application info", version="1.0.3") # pyright: ignore[reportUnknownMemberType] PrometheusMetrics has no typing 83 84 @app.route("/health") 85 @metrics.do_not_track() 86 def health() -> str: # pyright: ignore[reportUnusedFunction] Flask decorators aren't called by design 87 """Health check endpoint.""" 88 return "OK" 89 90 @app.route("/") 91 def root() -> str: # pyright: ignore[reportUnusedFunction] Flask decorators aren't called by design 92 """Root endpoint.""" 93 return "Hello, World!" 94 95 @app.route("/api/v1/offers", methods=["POST"]) 96 def create_offer(): # pyright: ignore[reportUnusedFunction] Flask decorators aren't called by design 97 """Create a credential offer for an achievement.""" 98 try: 99 bearer_token = self._bearer_token(request) 100 except MissingTokenError: 101 return json.dumps({"error": "Unauthorized"}), 401 102 103 raw: dict[str, str] = request.get_json(silent=True) or {} 104 body = CreateOfferBody(award_id=raw.get("award_id", "")) 105 106 try: 107 offer = self.offer_service.create_offer( 108 award_id=body.award_id, 109 bearer_token=bearer_token, 110 ) 111 except PermissionDeniedError: 112 return json.dumps({"error": "Forbidden"}), 403 113 114 return json.dumps({"offer_id": offer.offer_id, "uri": offer.uri}), 201 115 116 return app 117 118 def _parse_allowed_cors_domains(self) -> list[str]: 119 """Parse the allowed CORS domains from configuration. 120 121 Returns: 122 List of allowed origins. If the config value is "*", returns ["*"]. 123 Otherwise, splits the comma-separated list of domains. 124 """ 125 domains = self.config.allowed_cors_domains 126 if domains == "*": 127 return ["*"] 128 return [domain.strip() for domain in domains.split(",")] 129 130 @override 131 def run(self): 132 self.flask_app.run( 133 host=self.config.server_host, port=self.config.server_port, debug=True 134 ) 135 136 def _bearer_token(self, request: Request) -> str: 137 """Extract the bearer token from the Authorization header. 138 139 Args: 140 request: The incoming Flask request. 141 142 Returns: 143 The bearer token string. 144 145 Raises: 146 MissingTokenError: When the header is absent or the token is empty. 147 """ 148 auth_header = request.authorization 149 if not auth_header or not auth_header.token: 150 raise MissingTokenError() 151 return auth_header.token
class
MissingTokenError(builtins.Exception):
20class MissingTokenError(Exception): 21 """Raised when the Authorization header is absent or contains no token."""
Raised when the Authorization header is absent or contains no token.
@dataclass
class
CreateOfferBody:
24@dataclass 25class CreateOfferBody: 26 """Parsed request body for the create offer endpoint.""" 27 28 award_id: str
Parsed request body for the create offer endpoint.
@dataclass
class
Proof:
31@dataclass 32class Proof: 33 """Proof object for credential request.""" 34 35 proof_type: str 36 jwt: str
Proof object for credential request.
@dataclass
class
CredentialRequestBody:
39@dataclass 40class CredentialRequestBody: 41 """Parsed request body for the credential endpoint.""" 42 43 format: str 44 credential_configuration_id: str 45 proof: Proof 46 issuer_state: str
Parsed request body for the credential endpoint.
CredentialRequestBody( format: str, credential_configuration_id: str, proof: Proof, issuer_state: str)
proof: Proof
49class HttpApiAdapter(ApiPort): 50 """HTTP REST API adapter""" 51 52 flask_app: Flask 53 offer_service: OfferService 54 config: ConfigRepoPort 55 56 def __init__( 57 self, 58 config: ConfigRepoPort, 59 offer_service: OfferService, 60 ): 61 """Initialise the adapter. 62 63 Args: 64 config: Application configuration. 65 metadata_service: Service for credential issuer metadata. 66 offer_service: Service for creating credential offers. 67 credential_service: Service for requesting credentials. 68 """ 69 self.offer_service = offer_service 70 self.config = config 71 self.flask_app = self._flask_app() 72 73 def _flask_app(self) -> Flask: 74 app = Flask("HttpApi") 75 76 # Configure CORS 77 allowed_origins = self._parse_allowed_cors_domains() 78 _ = CORS(app, resources={r"/*": {"origins": allowed_origins}}) 79 80 # Metrics endpoint is only relevant to HttpAdapter 81 # no need for service/domain models 82 metrics: PrometheusMetrics = PrometheusMetrics(app) 83 _ = metrics.info("app_info", "Application info", version="1.0.3") # pyright: ignore[reportUnknownMemberType] PrometheusMetrics has no typing 84 85 @app.route("/health") 86 @metrics.do_not_track() 87 def health() -> str: # pyright: ignore[reportUnusedFunction] Flask decorators aren't called by design 88 """Health check endpoint.""" 89 return "OK" 90 91 @app.route("/") 92 def root() -> str: # pyright: ignore[reportUnusedFunction] Flask decorators aren't called by design 93 """Root endpoint.""" 94 return "Hello, World!" 95 96 @app.route("/api/v1/offers", methods=["POST"]) 97 def create_offer(): # pyright: ignore[reportUnusedFunction] Flask decorators aren't called by design 98 """Create a credential offer for an achievement.""" 99 try: 100 bearer_token = self._bearer_token(request) 101 except MissingTokenError: 102 return json.dumps({"error": "Unauthorized"}), 401 103 104 raw: dict[str, str] = request.get_json(silent=True) or {} 105 body = CreateOfferBody(award_id=raw.get("award_id", "")) 106 107 try: 108 offer = self.offer_service.create_offer( 109 award_id=body.award_id, 110 bearer_token=bearer_token, 111 ) 112 except PermissionDeniedError: 113 return json.dumps({"error": "Forbidden"}), 403 114 115 return json.dumps({"offer_id": offer.offer_id, "uri": offer.uri}), 201 116 117 return app 118 119 def _parse_allowed_cors_domains(self) -> list[str]: 120 """Parse the allowed CORS domains from configuration. 121 122 Returns: 123 List of allowed origins. If the config value is "*", returns ["*"]. 124 Otherwise, splits the comma-separated list of domains. 125 """ 126 domains = self.config.allowed_cors_domains 127 if domains == "*": 128 return ["*"] 129 return [domain.strip() for domain in domains.split(",")] 130 131 @override 132 def run(self): 133 self.flask_app.run( 134 host=self.config.server_host, port=self.config.server_port, debug=True 135 ) 136 137 def _bearer_token(self, request: Request) -> str: 138 """Extract the bearer token from the Authorization header. 139 140 Args: 141 request: The incoming Flask request. 142 143 Returns: 144 The bearer token string. 145 146 Raises: 147 MissingTokenError: When the header is absent or the token is empty. 148 """ 149 auth_header = request.authorization 150 if not auth_header or not auth_header.token: 151 raise MissingTokenError() 152 return auth_header.token
HTTP REST API adapter
HttpApiAdapter( config: src.config.config_port.ConfigRepoPort, offer_service: src.offers.offer_service.OfferService)
56 def __init__( 57 self, 58 config: ConfigRepoPort, 59 offer_service: OfferService, 60 ): 61 """Initialise the adapter. 62 63 Args: 64 config: Application configuration. 65 metadata_service: Service for credential issuer metadata. 66 offer_service: Service for creating credential offers. 67 credential_service: Service for requesting credentials. 68 """ 69 self.offer_service = offer_service 70 self.config = config 71 self.flask_app = self._flask_app()
Initialise the adapter.
Args: config: Application configuration. metadata_service: Service for credential issuer metadata. offer_service: Service for creating credential offers. credential_service: Service for requesting credentials.
offer_service: src.offers.offer_service.OfferService