Coverage for mcpgateway/services/resource_service.py: 30%
302 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-22 12:53 +0100
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-22 12:53 +0100
1# -*- coding: utf-8 -*-
2"""Resource Service Implementation.
4Copyright 2025
5SPDX-License-Identifier: Apache-2.0
6Authors: Mihai Criveti
8This module implements resource management according to the MCP specification.
9It handles:
10- Resource registration and retrieval
11- Resource templates and URI handling
12- Resource subscriptions and updates
13- Content type management
14- Active/inactive resource management
15"""
17import asyncio
18import logging
19import mimetypes
20import re
21from datetime import datetime
22from typing import Any, AsyncGenerator, Dict, List, Optional, Union
23from urllib.parse import urlparse
25import parse
26from sqlalchemy import delete, func, not_, select
27from sqlalchemy.exc import IntegrityError
28from sqlalchemy.orm import Session
30from mcpgateway.db import Resource as DbResource
31from mcpgateway.db import ResourceMetric
32from mcpgateway.db import ResourceSubscription as DbSubscription
33from mcpgateway.db import server_resource_association
34from mcpgateway.schemas import (
35 ResourceCreate,
36 ResourceMetrics,
37 ResourceRead,
38 ResourceSubscription,
39 ResourceUpdate,
40)
41from mcpgateway.types import ResourceContent, ResourceTemplate, TextContent
43logger = logging.getLogger(__name__)
46class ResourceError(Exception):
47 """Base class for resource-related errors."""
50class ResourceNotFoundError(ResourceError):
51 """Raised when a requested resource is not found."""
54class ResourceURIConflictError(ResourceError):
55 """Raised when a resource URI conflicts with existing (active or inactive) resource."""
57 def __init__(self, uri: str, is_active: bool = True, resource_id: Optional[int] = None):
58 """Initialize the error with resource information.
60 Args:
61 uri: The conflicting resource URI
62 is_active: Whether the existing resource is active
63 resource_id: ID of the existing resource if available
64 """
65 self.uri = uri
66 self.is_active = is_active
67 self.resource_id = resource_id
68 message = f"Resource already exists with URI: {uri}"
69 if not is_active: 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true
70 message += f" (currently inactive, ID: {resource_id})"
71 super().__init__(message)
74class ResourceValidationError(ResourceError):
75 """Raised when resource validation fails."""
78class ResourceService:
79 """Service for managing resources.
81 Handles:
82 - Resource registration and retrieval
83 - Resource templates and URIs
84 - Resource subscriptions
85 - Content type detection
86 - Active/inactive status management
87 """
89 def __init__(self):
90 """Initialize the resource service."""
91 self._event_subscribers: Dict[str, List[asyncio.Queue]] = {}
92 self._template_cache: Dict[str, ResourceTemplate] = {}
94 # Initialize mime types
95 mimetypes.init()
97 async def initialize(self) -> None:
98 """Initialize the service."""
99 logger.info("Initializing resource service")
101 async def shutdown(self) -> None:
102 """Shutdown the service."""
103 # Clear subscriptions
104 self._event_subscribers.clear()
105 logger.info("Resource service shutdown complete")
107 def _convert_resource_to_read(self, resource: DbResource) -> ResourceRead:
108 """
109 Converts a DbResource instance into a ResourceRead model, including aggregated metrics.
111 Args:
112 resource (DbResource): The ORM instance of the resource.
114 Returns:
115 ResourceRead: The Pydantic model representing the resource, including aggregated metrics.
116 """
117 resource_dict = resource.__dict__.copy()
118 # Remove SQLAlchemy state and any pre-existing 'metrics' attribute
119 resource_dict.pop("_sa_instance_state", None)
120 resource_dict.pop("metrics", None)
122 # Compute aggregated metrics from the resource's metrics list.
123 total = len(resource.metrics) if hasattr(resource, "metrics") and resource.metrics is not None else 0
124 successful = sum(1 for m in resource.metrics if m.is_success) if total > 0 else 0
125 failed = sum(1 for m in resource.metrics if not m.is_success) if total > 0 else 0
126 failure_rate = (failed / total) if total > 0 else 0.0
127 min_rt = min((m.response_time for m in resource.metrics), default=None) if total > 0 else None
128 max_rt = max((m.response_time for m in resource.metrics), default=None) if total > 0 else None
129 avg_rt = (sum(m.response_time for m in resource.metrics) / total) if total > 0 else None
130 last_time = max((m.timestamp for m in resource.metrics), default=None) if total > 0 else None
132 resource_dict["metrics"] = {
133 "total_executions": total,
134 "successful_executions": successful,
135 "failed_executions": failed,
136 "failure_rate": failure_rate,
137 "min_response_time": min_rt,
138 "max_response_time": max_rt,
139 "avg_response_time": avg_rt,
140 "last_execution_time": last_time,
141 }
142 return ResourceRead.model_validate(resource_dict)
144 async def register_resource(self, db: Session, resource: ResourceCreate) -> ResourceRead:
145 """Register a new resource.
147 Args:
148 db: Database session
149 resource: Resource creation schema
151 Returns:
152 Created resource information
154 Raises:
155 ResourceURIConflictError: If resource URI already exists
156 ResourceValidationError: If resource validation fails
157 ResourceError: For other resource registration errors
158 """
159 try:
160 # Check for URI conflicts (both active and inactive)
161 existing_resource = db.execute(select(DbResource).where(DbResource.uri == resource.uri)).scalar_one_or_none()
163 if existing_resource:
164 raise ResourceURIConflictError(
165 resource.uri,
166 is_active=existing_resource.is_active,
167 resource_id=existing_resource.id,
168 )
170 # Validate URI
171 if not self._is_valid_uri(resource.uri): 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true
172 raise ResourceValidationError(f"Invalid URI: {resource.uri}")
174 # Detect mime type if not provided
175 mime_type = resource.mime_type
176 if not mime_type: 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true
177 mime_type = self._detect_mime_type(resource.uri, resource.content)
179 # Determine content storage
180 is_text = mime_type and mime_type.startswith("text/") or isinstance(resource.content, str)
182 # Create DB model
183 db_resource = DbResource(
184 uri=resource.uri,
185 name=resource.name,
186 description=resource.description,
187 mime_type=mime_type,
188 template=resource.template,
189 text_content=resource.content if is_text else None,
190 binary_content=(resource.content.encode() if is_text and isinstance(resource.content, str) else resource.content if isinstance(resource.content, bytes) else None),
191 size=len(resource.content) if resource.content else 0,
192 )
194 # Add to DB
195 db.add(db_resource)
196 db.commit()
197 db.refresh(db_resource)
199 # Notify subscribers
200 await self._notify_resource_added(db_resource)
202 logger.info(f"Registered resource: {resource.uri}")
203 return self._convert_resource_to_read(db_resource)
205 except IntegrityError:
206 db.rollback()
207 raise ResourceError(f"Resource already exists: {resource.uri}")
208 except Exception as e:
209 db.rollback()
210 raise ResourceError(f"Failed to register resource: {str(e)}")
212 async def list_resources(self, db: Session, include_inactive: bool = False) -> List[ResourceRead]:
213 """
214 Retrieve a list of registered resources from the database.
216 This method retrieves resources from the database and converts them into a list
217 of ResourceRead objects. It supports filtering out inactive resources based on the
218 include_inactive parameter. The cursor parameter is reserved for future pagination support
219 but is currently not implemented.
221 Args:
222 db (Session): The SQLAlchemy database session.
223 include_inactive (bool): If True, include inactive resources in the result.
224 Defaults to False.
226 Returns:
227 List[ResourceRead]: A list of resources represented as ResourceRead objects.
228 """
229 query = select(DbResource)
230 if not include_inactive: 230 ↛ 233line 230 didn't jump to line 233 because the condition on line 230 was always true
231 query = query.where(DbResource.is_active)
232 # Cursor-based pagination logic can be implemented here in the future.
233 resources = db.execute(query).scalars().all()
234 return [self._convert_resource_to_read(r) for r in resources]
236 async def list_server_resources(self, db: Session, server_id: int, include_inactive: bool = False) -> List[ResourceRead]:
237 """
238 Retrieve a list of registered resources from the database.
240 This method retrieves resources from the database and converts them into a list
241 of ResourceRead objects. It supports filtering out inactive resources based on the
242 include_inactive parameter. The cursor parameter is reserved for future pagination support
243 but is currently not implemented.
245 Args:
246 db (Session): The SQLAlchemy database session.
247 server_id (int): Server ID
248 include_inactive (bool): If True, include inactive resources in the result.
249 Defaults to False.
251 Returns:
252 List[ResourceRead]: A list of resources represented as ResourceRead objects.
253 """
254 query = select(DbResource).join(server_resource_association, DbResource.id == server_resource_association.c.resource_id).where(server_resource_association.c.server_id == server_id)
255 if not include_inactive:
256 query = query.where(DbResource.is_active)
257 # Cursor-based pagination logic can be implemented here in the future.
258 resources = db.execute(query).scalars().all()
259 return [self._convert_resource_to_read(r) for r in resources]
261 async def read_resource(self, db: Session, uri: str) -> ResourceContent:
262 """Read a resource's content.
264 Args:
265 db: Database session
266 uri: Resource URI to read
268 Returns:
269 Resource content object
271 Raises:
272 ResourceNotFoundError: If resource not found
273 """
274 # Check for template
275 if "{" in uri and "}" in uri: 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true
276 return await self._read_template_resource(uri)
278 # Find resource
279 resource = db.execute(select(DbResource).where(DbResource.uri == uri).where(DbResource.is_active)).scalar_one_or_none()
281 if not resource:
282 # Check if inactive resource exists
283 inactive_resource = db.execute(select(DbResource).where(DbResource.uri == uri).where(not_(DbResource.is_active))).scalar_one_or_none()
285 if inactive_resource: 285 ↛ 286line 285 didn't jump to line 286 because the condition on line 285 was never true
286 raise ResourceNotFoundError(f"Resource '{uri}' exists but is inactive")
288 raise ResourceNotFoundError(f"Resource not found: {uri}")
290 # Return content
291 return resource.content
293 async def toggle_resource_status(self, db: Session, resource_id: int, activate: bool) -> ResourceRead:
294 """Toggle resource active status.
296 Args:
297 db: Database session
298 resource_id: Resource ID to toggle
299 activate: True to activate, False to deactivate
301 Returns:
302 Updated resource information
304 Raises:
305 ResourceNotFoundError: If resource not found
306 ResourceError: For other errors
307 """
308 try:
309 resource = db.get(DbResource, resource_id)
310 if not resource:
311 raise ResourceNotFoundError(f"Resource not found: {resource_id}")
313 # Update status if it's different
314 if resource.is_active != activate:
315 resource.is_active = activate
316 resource.updated_at = datetime.utcnow()
317 db.commit()
318 db.refresh(resource)
320 # Notify subscribers
321 if activate:
322 await self._notify_resource_activated(resource)
323 else:
324 await self._notify_resource_deactivated(resource)
326 logger.info(f"Resource {resource.uri} {'activated' if activate else 'deactivated'}")
328 return self._convert_resource_to_read(resource)
330 except Exception as e:
331 db.rollback()
332 raise ResourceError(f"Failed to toggle resource status: {str(e)}")
334 async def subscribe_resource(self, db: Session, subscription: ResourceSubscription) -> None:
335 """Subscribe to resource updates.
337 Args:
338 db: Database session
339 subscription: Subscription details
341 Raises:
342 ResourceNotFoundError: If resource not found
343 ResourceError: For other subscription errors
344 """
345 try:
346 # Verify resource exists
347 resource = db.execute(select(DbResource).where(DbResource.uri == subscription.uri).where(DbResource.is_active)).scalar_one_or_none()
349 if not resource:
350 # Check if inactive resource exists
351 inactive_resource = db.execute(select(DbResource).where(DbResource.uri == subscription.uri).where(not_(DbResource.is_active))).scalar_one_or_none()
353 if inactive_resource:
354 raise ResourceNotFoundError(f"Resource '{subscription.uri}' exists but is inactive")
356 raise ResourceNotFoundError(f"Resource not found: {subscription.uri}")
358 # Create subscription
359 db_sub = DbSubscription(resource_id=resource.id, subscriber_id=subscription.subscriber_id)
360 db.add(db_sub)
361 db.commit()
363 logger.info(f"Added subscription for {subscription.uri} by {subscription.subscriber_id}")
365 except Exception as e:
366 db.rollback()
367 raise ResourceError(f"Failed to subscribe: {str(e)}")
369 async def unsubscribe_resource(self, db: Session, subscription: ResourceSubscription) -> None:
370 """Unsubscribe from resource updates.
372 Args:
373 db: Database session
374 subscription: Subscription to remove
375 """
376 try:
377 # Find resource
378 resource = db.execute(select(DbResource).where(DbResource.uri == subscription.uri)).scalar_one_or_none()
380 if not resource:
381 return
383 # Remove subscription
384 db.execute(select(DbSubscription).where(DbSubscription.resource_id == resource.id).where(DbSubscription.subscriber_id == subscription.subscriber_id)).delete()
385 db.commit()
387 logger.info(f"Removed subscription for {subscription.uri} by {subscription.subscriber_id}")
389 except Exception as e:
390 db.rollback()
391 logger.error(f"Failed to unsubscribe: {str(e)}")
393 async def update_resource(self, db: Session, uri: str, resource_update: ResourceUpdate) -> ResourceRead:
394 """Update a resource.
396 Args:
397 db: Database session
398 uri: Resource URI to update
399 resource_update: Updated resource data
401 Returns:
402 Updated resource information
404 Raises:
405 ResourceNotFoundError: If resource not found
406 ResourceError: For other update errors
407 Exception: If resource not found
408 """
409 try:
410 # Find resource
411 resource = db.execute(select(DbResource).where(DbResource.uri == uri).where(DbResource.is_active)).scalar_one_or_none()
413 if not resource:
414 # Check if inactive resource exists
415 inactive_resource = db.execute(select(DbResource).where(DbResource.uri == uri).where(not_(DbResource.is_active))).scalar_one_or_none()
417 if inactive_resource:
418 raise ResourceNotFoundError(f"Resource '{uri}' exists but is inactive")
420 raise ResourceNotFoundError(f"Resource not found: {uri}")
422 # Update fields if provided
423 if resource_update.name is not None:
424 resource.name = resource_update.name
425 if resource_update.description is not None:
426 resource.description = resource_update.description
427 if resource_update.mime_type is not None:
428 resource.mime_type = resource_update.mime_type
429 if resource_update.template is not None:
430 resource.template = resource_update.template
432 # Update content if provided
433 if resource_update.content is not None:
434 # Determine content storage
435 is_text = resource.mime_type and resource.mime_type.startswith("text/") or isinstance(resource_update.content, str)
437 resource.text_content = resource_update.content if is_text else None
438 resource.binary_content = (
439 resource_update.content.encode() if is_text and isinstance(resource_update.content, str) else resource_update.content if isinstance(resource_update.content, bytes) else None
440 )
441 resource.size = len(resource_update.content)
443 resource.updated_at = datetime.utcnow()
444 db.commit()
445 db.refresh(resource)
447 # Notify subscribers
448 await self._notify_resource_updated(resource)
450 logger.info(f"Updated resource: {uri}")
451 return self._convert_resource_to_read(resource)
453 except Exception as e:
454 db.rollback()
455 if isinstance(e, ResourceNotFoundError):
456 raise e
457 raise ResourceError(f"Failed to update resource: {str(e)}")
459 async def delete_resource(self, db: Session, uri: str) -> None:
460 """Permanently delete a resource.
462 Args:
463 db: Database session
464 uri: Resource URI to delete
466 Raises:
467 ResourceNotFoundError: If resource not found
468 ResourceError: For other deletion errors
469 """
470 try:
471 # Find resource by its URI.
472 resource = db.execute(select(DbResource).where(DbResource.uri == uri)).scalar_one_or_none()
474 if not resource: 474 ↛ 476line 474 didn't jump to line 476 because the condition on line 474 was never true
475 # If resource doesn't exist, rollback and re-raise a ResourceNotFoundError.
476 db.rollback()
477 raise ResourceNotFoundError(f"Resource not found: {uri}")
479 # Store resource info for notification before deletion.
480 resource_info = {
481 "id": resource.id,
482 "uri": resource.uri,
483 "name": resource.name,
484 }
486 # Remove subscriptions using SQLAlchemy's delete() expression.
487 db.execute(delete(DbSubscription).where(DbSubscription.resource_id == resource.id))
489 # Hard delete the resource.
490 db.delete(resource)
491 db.commit()
493 # Notify subscribers.
494 await self._notify_resource_deleted(resource_info)
496 logger.info(f"Permanently deleted resource: {uri}")
498 except ResourceNotFoundError:
499 # ResourceNotFoundError is re-raised to be handled in the endpoint.
500 raise
501 except Exception as e:
502 db.rollback()
503 raise ResourceError(f"Failed to delete resource: {str(e)}")
505 async def get_resource_by_uri(self, db: Session, uri: str, include_inactive: bool = False) -> ResourceRead:
506 """Get resource by URI.
508 Args:
509 db: Database session
510 uri: Resource URI
511 include_inactive: Whether to include inactive resources
513 Returns:
514 Resource information
516 Raises:
517 ResourceNotFoundError: If resource not found
518 """
519 query = select(DbResource).where(DbResource.uri == uri)
521 if not include_inactive:
522 query = query.where(DbResource.is_active)
524 resource = db.execute(query).scalar_one_or_none()
526 if not resource:
527 if not include_inactive:
528 # Check if inactive resource exists
529 inactive_resource = db.execute(select(DbResource).where(DbResource.uri == uri).where(not_(DbResource.is_active))).scalar_one_or_none()
531 if inactive_resource:
532 raise ResourceNotFoundError(f"Resource '{uri}' exists but is inactive")
534 raise ResourceNotFoundError(f"Resource not found: {uri}")
536 return self._convert_resource_to_read(resource)
538 async def _notify_resource_activated(self, resource: DbResource) -> None:
539 """
540 Notify subscribers of resource activation.
542 Args:
543 resource: Resource to activate
544 """
545 event = {
546 "type": "resource_activated",
547 "data": {
548 "id": resource.id,
549 "uri": resource.uri,
550 "name": resource.name,
551 "is_active": True,
552 },
553 "timestamp": datetime.utcnow().isoformat(),
554 }
555 await self._publish_event(resource.uri, event)
557 async def _notify_resource_deactivated(self, resource: DbResource) -> None:
558 """
559 Notify subscribers of resource deactivation.
561 Args:
562 resource: Resource to deactivate
563 """
564 event = {
565 "type": "resource_deactivated",
566 "data": {
567 "id": resource.id,
568 "uri": resource.uri,
569 "name": resource.name,
570 "is_active": False,
571 },
572 "timestamp": datetime.utcnow().isoformat(),
573 }
574 await self._publish_event(resource.uri, event)
576 async def _notify_resource_deleted(self, resource_info: Dict[str, Any]) -> None:
577 """
578 Notify subscribers of resource deletion.
580 Args:
581 resource_info: Dictionary of resource to delete
582 """
583 event = {
584 "type": "resource_deleted",
585 "data": resource_info,
586 "timestamp": datetime.utcnow().isoformat(),
587 }
588 await self._publish_event(resource_info["uri"], event)
590 async def _notify_resource_removed(self, resource: DbResource) -> None:
591 """
592 Notify subscribers of resource removal.
594 Args:
595 resource: Resource to remove
596 """
597 event = {
598 "type": "resource_removed",
599 "data": {
600 "id": resource.id,
601 "uri": resource.uri,
602 "name": resource.name,
603 "is_active": False,
604 },
605 "timestamp": datetime.utcnow().isoformat(),
606 }
607 await self._publish_event(resource.uri, event)
609 async def subscribe_events(self, uri: Optional[str] = None) -> AsyncGenerator[Dict[str, Any], None]:
610 """Subscribe to resource events.
612 Args:
613 uri: Optional URI to filter events
615 Yields:
616 Resource event messages
617 """
618 queue: asyncio.Queue = asyncio.Queue()
620 if uri:
621 if uri not in self._event_subscribers:
622 self._event_subscribers[uri] = []
623 self._event_subscribers[uri].append(queue)
624 else:
625 self._event_subscribers["*"] = self._event_subscribers.get("*", [])
626 self._event_subscribers["*"].append(queue)
628 try:
629 while True:
630 event = await queue.get()
631 yield event
632 finally:
633 if uri:
634 self._event_subscribers[uri].remove(queue)
635 if not self._event_subscribers[uri]:
636 del self._event_subscribers[uri]
637 else:
638 self._event_subscribers["*"].remove(queue)
639 if not self._event_subscribers["*"]:
640 del self._event_subscribers["*"]
642 def _is_valid_uri(self, uri: str) -> bool:
643 """Validate a resource URI.
645 Args:
646 uri: URI to validate
648 Returns:
649 True if URI is valid
650 """
651 try:
652 parsed = urlparse(uri)
653 return bool(parsed.scheme and parsed.path)
654 except Exception:
655 return False
657 def _detect_mime_type(self, uri: str, content: Union[str, bytes]) -> str:
658 """Detect mime type from URI and content.
660 Args:
661 uri: Resource URI
662 content: Resource content
664 Returns:
665 Detected mime type
666 """
667 # Try from URI first
668 mime_type, _ = mimetypes.guess_type(uri)
669 if mime_type:
670 return mime_type
672 # Check content type
673 if isinstance(content, str):
674 return "text/plain"
676 return "application/octet-stream"
678 async def _read_template_resource(self, uri: str) -> ResourceContent:
679 """Read a templated resource.
681 Args:
682 uri: Template URI with parameters
684 Returns:
685 Resource content
687 Raises:
688 ResourceNotFoundError: If template not found
689 ResourceError: For other template errors
690 NotImplementedError: When binary template is passed
691 """
692 # Find matching template
693 template = None
694 for cached in self._template_cache.values():
695 if self._uri_matches_template(uri, cached.uri_template):
696 template = cached
697 break
699 if not template:
700 raise ResourceNotFoundError(f"No template matches URI: {uri}")
702 try:
703 # Extract parameters
704 params = self._extract_template_params(uri, template.uri_template)
706 # Generate content
707 if template.mime_type and template.mime_type.startswith("text/"):
708 content = template.uri_template.format(**params)
709 return TextContent(type="text", text=content)
711 # Handle binary template
712 raise NotImplementedError("Binary resource templates not yet supported")
714 except Exception as e:
715 raise ResourceError(f"Failed to process template: {str(e)}")
717 def _uri_matches_template(self, uri: str, template: str) -> bool:
718 """Check if URI matches a template pattern.
720 Args:
721 uri: URI to check
722 template: Template pattern
724 Returns:
725 True if URI matches template
726 """
727 # Convert template to regex pattern
729 pattern = re.escape(template).replace(r"\{.*?\}", r"[^/]+")
730 return bool(re.match(pattern, uri))
732 def _extract_template_params(self, uri: str, template: str) -> Dict[str, str]:
733 """Extract parameters from URI based on template.
735 Args:
736 uri: URI with parameter values
737 template: Template pattern
739 Returns:
740 Dict of parameter names and values
741 """
743 result = parse.parse(template, uri)
744 return result.named if result else {}
746 async def _notify_resource_added(self, resource: DbResource) -> None:
747 """
748 Notify subscribers of resource addition.
750 Args:
751 resource: Resource to add
752 """
753 event = {
754 "type": "resource_added",
755 "data": {
756 "id": resource.id,
757 "uri": resource.uri,
758 "name": resource.name,
759 "description": resource.description,
760 "is_active": resource.is_active,
761 },
762 "timestamp": datetime.utcnow().isoformat(),
763 }
764 await self._publish_event(resource.uri, event)
766 async def _notify_resource_updated(self, resource: DbResource) -> None:
767 """
768 Notify subscribers of resource update.
770 Args:
771 resource: Resource to update
772 """
773 event = {
774 "type": "resource_updated",
775 "data": {
776 "id": resource.id,
777 "uri": resource.uri,
778 "content": resource.content,
779 "is_active": resource.is_active,
780 },
781 "timestamp": datetime.utcnow().isoformat(),
782 }
783 await self._publish_event(resource.uri, event)
785 async def _publish_event(self, uri: str, event: Dict[str, Any]) -> None:
786 """Publish event to relevant subscribers.
788 Args:
789 uri: Resource URI event relates to
790 event: Event data to publish
791 """
792 # Notify resource-specific subscribers
793 if uri in self._event_subscribers:
794 for queue in self._event_subscribers[uri]:
795 await queue.put(event)
797 # Notify global subscribers
798 if "*" in self._event_subscribers:
799 for queue in self._event_subscribers["*"]:
800 await queue.put(event)
802 # --- Resource templates ---
803 async def list_resource_templates(self, db: Session, include_inactive: bool = False) -> List[ResourceTemplate]:
804 """
805 Retrieve a list of resource templates from the database.
807 This method retrieves resource templates (resources with a defined template field) from the database
808 and converts them into a list of ResourceTemplate objects. It supports filtering out inactive templates
809 based on the include_inactive parameter. The cursor parameter is reserved for future pagination support
810 but is currently not implemented.
812 Args:
813 db (Session): The SQLAlchemy database session.
814 include_inactive (bool): If True, include inactive resource templates in the result.
815 Defaults to False.
817 Returns:
818 List[ResourceTemplate]: A list of resource templates.
819 """
820 query = select(DbResource).where(DbResource.template.isnot(None))
821 if not include_inactive:
822 query = query.where(DbResource.is_active)
823 # Cursor-based pagination logic can be implemented here in the future.
824 templates = db.execute(query).scalars().all()
825 return [ResourceTemplate.model_validate(t) for t in templates]
827 # --- Metrics ---
828 async def aggregate_metrics(self, db: Session) -> ResourceMetrics:
829 """
830 Aggregate metrics for all resource invocations across all resources.
832 Args:
833 db: Database session
835 Returns:
836 ResourceMetrics: Aggregated metrics computed from all ResourceMetric records.
837 """
838 total_executions = db.execute(select(func.count()).select_from(ResourceMetric)).scalar() or 0 # pylint: disable=not-callable
840 successful_executions = db.execute(select(func.count()).select_from(ResourceMetric).where(ResourceMetric.is_success)).scalar() or 0 # pylint: disable=not-callable
842 failed_executions = db.execute(select(func.count()).select_from(ResourceMetric).where(not_(ResourceMetric.is_success))).scalar() or 0 # pylint: disable=not-callable
844 min_response_time = db.execute(select(func.min(ResourceMetric.response_time))).scalar()
846 max_response_time = db.execute(select(func.max(ResourceMetric.response_time))).scalar()
848 avg_response_time = db.execute(select(func.avg(ResourceMetric.response_time))).scalar()
850 last_execution_time = db.execute(select(func.max(ResourceMetric.timestamp))).scalar()
852 return ResourceMetrics(
853 total_executions=total_executions,
854 successful_executions=successful_executions,
855 failed_executions=failed_executions,
856 failure_rate=(failed_executions / total_executions) if total_executions > 0 else 0.0,
857 min_response_time=min_response_time,
858 max_response_time=max_response_time,
859 avg_response_time=avg_response_time,
860 last_execution_time=last_execution_time,
861 )
863 async def reset_metrics(self, db: Session) -> None:
864 """
865 Reset all resource metrics by deleting all records from the resource metrics table.
867 Args:
868 db: Database session
869 """
870 db.execute(delete(ResourceMetric))
871 db.commit()