Coverage for mcpgateway/services/resource_service.py: 89%
302 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-09 11:03 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-09 11:03 +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"""
17# Standard
18import asyncio
19from datetime import datetime, timezone
20import logging
21import mimetypes
22import re
23from typing import Any, AsyncGenerator, Dict, List, Optional, Union
24from urllib.parse import urlparse
26# Third-Party
27import parse
28from sqlalchemy import delete, func, not_, select
29from sqlalchemy.exc import IntegrityError
30from sqlalchemy.orm import Session
32# First-Party
33from mcpgateway.db import Resource as DbResource
34from mcpgateway.db import ResourceMetric
35from mcpgateway.db import ResourceSubscription as DbSubscription
36from mcpgateway.db import server_resource_association
37from mcpgateway.models import ResourceContent, ResourceTemplate, TextContent
38from mcpgateway.schemas import (
39 ResourceCreate,
40 ResourceMetrics,
41 ResourceRead,
42 ResourceSubscription,
43 ResourceUpdate,
44)
46logger = logging.getLogger(__name__)
49class ResourceError(Exception):
50 """Base class for resource-related errors."""
53class ResourceNotFoundError(ResourceError):
54 """Raised when a requested resource is not found."""
57class ResourceURIConflictError(ResourceError):
58 """Raised when a resource URI conflicts with existing (active or inactive) resource."""
60 def __init__(self, uri: str, is_active: bool = True, resource_id: Optional[int] = None):
61 """Initialize the error with resource information.
63 Args:
64 uri: The conflicting resource URI
65 is_active: Whether the existing resource is active
66 resource_id: ID of the existing resource if available
67 """
68 self.uri = uri
69 self.is_active = is_active
70 self.resource_id = resource_id
71 message = f"Resource already exists with URI: {uri}"
72 if not is_active:
73 message += f" (currently inactive, ID: {resource_id})"
74 super().__init__(message)
77class ResourceValidationError(ResourceError):
78 """Raised when resource validation fails."""
81class ResourceService:
82 """Service for managing resources.
84 Handles:
85 - Resource registration and retrieval
86 - Resource templates and URIs
87 - Resource subscriptions
88 - Content type detection
89 - Active/inactive status management
90 """
92 def __init__(self):
93 """Initialize the resource service."""
94 self._event_subscribers: Dict[str, List[asyncio.Queue]] = {}
95 self._template_cache: Dict[str, ResourceTemplate] = {}
97 # Initialize mime types
98 mimetypes.init()
100 async def initialize(self) -> None:
101 """Initialize the service."""
102 logger.info("Initializing resource service")
104 async def shutdown(self) -> None:
105 """Shutdown the service."""
106 # Clear subscriptions
107 self._event_subscribers.clear()
108 logger.info("Resource service shutdown complete")
110 def _convert_resource_to_read(self, resource: DbResource) -> ResourceRead:
111 """
112 Converts a DbResource instance into a ResourceRead model, including aggregated metrics.
114 Args:
115 resource (DbResource): The ORM instance of the resource.
117 Returns:
118 ResourceRead: The Pydantic model representing the resource, including aggregated metrics.
119 """
120 resource_dict = resource.__dict__.copy()
121 # Remove SQLAlchemy state and any pre-existing 'metrics' attribute
122 resource_dict.pop("_sa_instance_state", None)
123 resource_dict.pop("metrics", None)
125 # Compute aggregated metrics from the resource's metrics list.
126 total = len(resource.metrics) if hasattr(resource, "metrics") and resource.metrics is not None else 0
127 successful = sum(1 for m in resource.metrics if m.is_success) if total > 0 else 0
128 failed = sum(1 for m in resource.metrics if not m.is_success) if total > 0 else 0
129 failure_rate = (failed / total) if total > 0 else 0.0
130 min_rt = min((m.response_time for m in resource.metrics), default=None) if total > 0 else None
131 max_rt = max((m.response_time for m in resource.metrics), default=None) if total > 0 else None
132 avg_rt = (sum(m.response_time for m in resource.metrics) / total) if total > 0 else None
133 last_time = max((m.timestamp for m in resource.metrics), default=None) if total > 0 else None
135 resource_dict["metrics"] = {
136 "total_executions": total,
137 "successful_executions": successful,
138 "failed_executions": failed,
139 "failure_rate": failure_rate,
140 "min_response_time": min_rt,
141 "max_response_time": max_rt,
142 "avg_response_time": avg_rt,
143 "last_execution_time": last_time,
144 }
145 return ResourceRead.model_validate(resource_dict)
147 async def register_resource(self, db: Session, resource: ResourceCreate) -> ResourceRead:
148 """Register a new resource.
150 Args:
151 db: Database session
152 resource: Resource creation schema
154 Returns:
155 Created resource information
157 Raises:
158 ResourceURIConflictError: If resource URI already exists
159 ResourceValidationError: If resource validation fails
160 ResourceError: For other resource registration errors
161 """
162 try:
163 # Check for URI conflicts (both active and inactive)
164 existing_resource = db.execute(select(DbResource).where(DbResource.uri == resource.uri)).scalar_one_or_none()
166 if existing_resource:
167 raise ResourceURIConflictError(
168 resource.uri,
169 is_active=existing_resource.is_active,
170 resource_id=existing_resource.id,
171 )
173 # Validate URI
174 if not self._is_valid_uri(resource.uri):
175 raise ResourceValidationError(f"Invalid URI: {resource.uri}")
177 # Detect mime type if not provided
178 mime_type = resource.mime_type
179 if not mime_type: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true
180 mime_type = self._detect_mime_type(resource.uri, resource.content)
182 # Determine content storage
183 is_text = mime_type and mime_type.startswith("text/") or isinstance(resource.content, str)
185 # Create DB model
186 db_resource = DbResource(
187 uri=resource.uri,
188 name=resource.name,
189 description=resource.description,
190 mime_type=mime_type,
191 template=resource.template,
192 text_content=resource.content if is_text else None,
193 binary_content=(resource.content.encode() if is_text and isinstance(resource.content, str) else resource.content if isinstance(resource.content, bytes) else None),
194 size=len(resource.content) if resource.content else 0,
195 )
197 # Add to DB
198 db.add(db_resource)
199 db.commit()
200 db.refresh(db_resource)
202 # Notify subscribers
203 await self._notify_resource_added(db_resource)
205 logger.info(f"Registered resource: {resource.uri}")
206 return self._convert_resource_to_read(db_resource)
208 except IntegrityError:
209 db.rollback()
210 raise ResourceError(f"Resource already exists: {resource.uri}")
211 except Exception as e:
212 db.rollback()
213 raise ResourceError(f"Failed to register resource: {str(e)}")
215 async def list_resources(self, db: Session, include_inactive: bool = False) -> List[ResourceRead]:
216 """
217 Retrieve a list of registered resources from the database.
219 This method retrieves resources from the database and converts them into a list
220 of ResourceRead objects. It supports filtering out inactive resources based on the
221 include_inactive parameter. The cursor parameter is reserved for future pagination support
222 but is currently not implemented.
224 Args:
225 db (Session): The SQLAlchemy database session.
226 include_inactive (bool): If True, include inactive resources in the result.
227 Defaults to False.
229 Returns:
230 List[ResourceRead]: A list of resources represented as ResourceRead objects.
231 """
232 query = select(DbResource)
233 if not include_inactive:
234 query = query.where(DbResource.is_active)
235 # Cursor-based pagination logic can be implemented here in the future.
236 resources = db.execute(query).scalars().all()
237 return [self._convert_resource_to_read(r) for r in resources]
239 async def list_server_resources(self, db: Session, server_id: str, include_inactive: bool = False) -> List[ResourceRead]:
240 """
241 Retrieve a list of registered resources from the database.
243 This method retrieves resources from the database and converts them into a list
244 of ResourceRead objects. It supports filtering out inactive resources based on the
245 include_inactive parameter. The cursor parameter is reserved for future pagination support
246 but is currently not implemented.
248 Args:
249 db (Session): The SQLAlchemy database session.
250 server_id (str): Server ID
251 include_inactive (bool): If True, include inactive resources in the result.
252 Defaults to False.
254 Returns:
255 List[ResourceRead]: A list of resources represented as ResourceRead objects.
256 """
257 query = select(DbResource).join(server_resource_association, DbResource.id == server_resource_association.c.resource_id).where(server_resource_association.c.server_id == server_id)
258 if not include_inactive: 258 ↛ 261line 258 didn't jump to line 261 because the condition on line 258 was always true
259 query = query.where(DbResource.is_active)
260 # Cursor-based pagination logic can be implemented here in the future.
261 resources = db.execute(query).scalars().all()
262 return [self._convert_resource_to_read(r) for r in resources]
264 async def read_resource(self, db: Session, uri: str) -> ResourceContent:
265 """Read a resource's content.
267 Args:
268 db: Database session
269 uri: Resource URI to read
271 Returns:
272 Resource content object
274 Raises:
275 ResourceNotFoundError: If resource not found
276 """
277 # Check for template
278 if "{" in uri and "}" in uri:
279 return await self._read_template_resource(uri)
281 # Find resource
282 resource = db.execute(select(DbResource).where(DbResource.uri == uri).where(DbResource.is_active)).scalar_one_or_none()
284 if not resource:
285 # Check if inactive resource exists
286 inactive_resource = db.execute(select(DbResource).where(DbResource.uri == uri).where(not_(DbResource.is_active))).scalar_one_or_none()
288 if inactive_resource:
289 raise ResourceNotFoundError(f"Resource '{uri}' exists but is inactive")
291 raise ResourceNotFoundError(f"Resource not found: {uri}")
293 # Return content
294 return resource.content
296 async def toggle_resource_status(self, db: Session, resource_id: int, activate: bool) -> ResourceRead:
297 """Toggle resource active status.
299 Args:
300 db: Database session
301 resource_id: Resource ID to toggle
302 activate: True to activate, False to deactivate
304 Returns:
305 Updated resource information
307 Raises:
308 ResourceNotFoundError: If resource not found
309 ResourceError: For other errors
310 """
311 try:
312 resource = db.get(DbResource, resource_id)
313 if not resource:
314 raise ResourceNotFoundError(f"Resource not found: {resource_id}")
316 # Update status if it's different
317 if resource.is_active != activate:
318 resource.is_active = activate
319 resource.updated_at = datetime.now(timezone.utc)
320 db.commit()
321 db.refresh(resource)
323 # Notify subscribers
324 if activate:
325 await self._notify_resource_activated(resource)
326 else:
327 await self._notify_resource_deactivated(resource)
329 logger.info(f"Resource {resource.uri} {'activated' if activate else 'deactivated'}")
331 return self._convert_resource_to_read(resource)
333 except Exception as e:
334 db.rollback()
335 raise ResourceError(f"Failed to toggle resource status: {str(e)}")
337 async def subscribe_resource(self, db: Session, subscription: ResourceSubscription) -> None:
338 """Subscribe to resource updates.
340 Args:
341 db: Database session
342 subscription: Subscription details
344 Raises:
345 ResourceNotFoundError: If resource not found
346 ResourceError: For other subscription errors
347 """
348 try:
349 # Verify resource exists
350 resource = db.execute(select(DbResource).where(DbResource.uri == subscription.uri).where(DbResource.is_active)).scalar_one_or_none()
352 if not resource:
353 # Check if inactive resource exists
354 inactive_resource = db.execute(select(DbResource).where(DbResource.uri == subscription.uri).where(not_(DbResource.is_active))).scalar_one_or_none()
356 if inactive_resource:
357 raise ResourceNotFoundError(f"Resource '{subscription.uri}' exists but is inactive")
359 raise ResourceNotFoundError(f"Resource not found: {subscription.uri}")
361 # Create subscription
362 db_sub = DbSubscription(resource_id=resource.id, subscriber_id=subscription.subscriber_id)
363 db.add(db_sub)
364 db.commit()
366 logger.info(f"Added subscription for {subscription.uri} by {subscription.subscriber_id}")
368 except Exception as e:
369 db.rollback()
370 raise ResourceError(f"Failed to subscribe: {str(e)}")
372 async def unsubscribe_resource(self, db: Session, subscription: ResourceSubscription) -> None:
373 """Unsubscribe from resource updates.
375 Args:
376 db: Database session
377 subscription: Subscription to remove
378 """
379 try:
380 # Find resource
381 resource = db.execute(select(DbResource).where(DbResource.uri == subscription.uri)).scalar_one_or_none()
383 if not resource:
384 return
386 # Remove subscription
387 db.execute(select(DbSubscription).where(DbSubscription.resource_id == resource.id).where(DbSubscription.subscriber_id == subscription.subscriber_id)).delete()
388 db.commit()
390 logger.info(f"Removed subscription for {subscription.uri} by {subscription.subscriber_id}")
392 except Exception as e:
393 db.rollback()
394 logger.error(f"Failed to unsubscribe: {str(e)}")
396 async def update_resource(self, db: Session, uri: str, resource_update: ResourceUpdate) -> ResourceRead:
397 """Update a resource.
399 Args:
400 db: Database session
401 uri: Resource URI to update
402 resource_update: Updated resource data
404 Returns:
405 Updated resource information
407 Raises:
408 ResourceNotFoundError: If resource not found
409 ResourceError: For other update errors
410 Exception: If resource not found
411 """
412 try:
413 # Find resource
414 resource = db.execute(select(DbResource).where(DbResource.uri == uri).where(DbResource.is_active)).scalar_one_or_none()
416 if not resource:
417 # Check if inactive resource exists
418 inactive_resource = db.execute(select(DbResource).where(DbResource.uri == uri).where(not_(DbResource.is_active))).scalar_one_or_none()
420 if inactive_resource:
421 raise ResourceNotFoundError(f"Resource '{uri}' exists but is inactive")
423 raise ResourceNotFoundError(f"Resource not found: {uri}")
425 # Update fields if provided
426 if resource_update.name is not None:
427 resource.name = resource_update.name
428 if resource_update.description is not None:
429 resource.description = resource_update.description
430 if resource_update.mime_type is not None: 430 ↛ 431line 430 didn't jump to line 431 because the condition on line 430 was never true
431 resource.mime_type = resource_update.mime_type
432 if resource_update.template is not None: 432 ↛ 433line 432 didn't jump to line 433 because the condition on line 432 was never true
433 resource.template = resource_update.template
435 # Update content if provided
436 if resource_update.content is not None:
437 # Determine content storage
438 is_text = resource.mime_type and resource.mime_type.startswith("text/") or isinstance(resource_update.content, str)
440 resource.text_content = resource_update.content if is_text else None
441 resource.binary_content = (
442 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
443 )
444 resource.size = len(resource_update.content)
446 resource.updated_at = datetime.now(timezone.utc)
447 db.commit()
448 db.refresh(resource)
450 # Notify subscribers
451 await self._notify_resource_updated(resource)
453 logger.info(f"Updated resource: {uri}")
454 return self._convert_resource_to_read(resource)
456 except Exception as e:
457 db.rollback()
458 if isinstance(e, ResourceNotFoundError):
459 raise e
460 raise ResourceError(f"Failed to update resource: {str(e)}")
462 async def delete_resource(self, db: Session, uri: str) -> None:
463 """Permanently delete a resource.
465 Args:
466 db: Database session
467 uri: Resource URI to delete
469 Raises:
470 ResourceNotFoundError: If resource not found
471 ResourceError: For other deletion errors
472 """
473 try:
474 # Find resource by its URI.
475 resource = db.execute(select(DbResource).where(DbResource.uri == uri)).scalar_one_or_none()
477 if not resource:
478 # If resource doesn't exist, rollback and re-raise a ResourceNotFoundError.
479 db.rollback()
480 raise ResourceNotFoundError(f"Resource not found: {uri}")
482 # Store resource info for notification before deletion.
483 resource_info = {
484 "id": resource.id,
485 "uri": resource.uri,
486 "name": resource.name,
487 }
489 # Remove subscriptions using SQLAlchemy's delete() expression.
490 db.execute(delete(DbSubscription).where(DbSubscription.resource_id == resource.id))
492 # Hard delete the resource.
493 db.delete(resource)
494 db.commit()
496 # Notify subscribers.
497 await self._notify_resource_deleted(resource_info)
499 logger.info(f"Permanently deleted resource: {uri}")
501 except ResourceNotFoundError:
502 # ResourceNotFoundError is re-raised to be handled in the endpoint.
503 raise
504 except Exception as e:
505 db.rollback()
506 raise ResourceError(f"Failed to delete resource: {str(e)}")
508 async def get_resource_by_uri(self, db: Session, uri: str, include_inactive: bool = False) -> ResourceRead:
509 """Get resource by URI.
511 Args:
512 db: Database session
513 uri: Resource URI
514 include_inactive: Whether to include inactive resources
516 Returns:
517 Resource information
519 Raises:
520 ResourceNotFoundError: If resource not found
521 """
522 query = select(DbResource).where(DbResource.uri == uri)
524 if not include_inactive:
525 query = query.where(DbResource.is_active)
527 resource = db.execute(query).scalar_one_or_none()
529 if not resource:
530 if not include_inactive: 530 ↛ 537line 530 didn't jump to line 537 because the condition on line 530 was always true
531 # Check if inactive resource exists
532 inactive_resource = db.execute(select(DbResource).where(DbResource.uri == uri).where(not_(DbResource.is_active))).scalar_one_or_none()
534 if inactive_resource:
535 raise ResourceNotFoundError(f"Resource '{uri}' exists but is inactive")
537 raise ResourceNotFoundError(f"Resource not found: {uri}")
539 return self._convert_resource_to_read(resource)
541 async def _notify_resource_activated(self, resource: DbResource) -> None:
542 """
543 Notify subscribers of resource activation.
545 Args:
546 resource: Resource to activate
547 """
548 event = {
549 "type": "resource_activated",
550 "data": {
551 "id": resource.id,
552 "uri": resource.uri,
553 "name": resource.name,
554 "is_active": True,
555 },
556 "timestamp": datetime.now(timezone.utc).isoformat(),
557 }
558 await self._publish_event(resource.uri, event)
560 async def _notify_resource_deactivated(self, resource: DbResource) -> None:
561 """
562 Notify subscribers of resource deactivation.
564 Args:
565 resource: Resource to deactivate
566 """
567 event = {
568 "type": "resource_deactivated",
569 "data": {
570 "id": resource.id,
571 "uri": resource.uri,
572 "name": resource.name,
573 "is_active": False,
574 },
575 "timestamp": datetime.now(timezone.utc).isoformat(),
576 }
577 await self._publish_event(resource.uri, event)
579 async def _notify_resource_deleted(self, resource_info: Dict[str, Any]) -> None:
580 """
581 Notify subscribers of resource deletion.
583 Args:
584 resource_info: Dictionary of resource to delete
585 """
586 event = {
587 "type": "resource_deleted",
588 "data": resource_info,
589 "timestamp": datetime.now(timezone.utc).isoformat(),
590 }
591 await self._publish_event(resource_info["uri"], event)
593 async def _notify_resource_removed(self, resource: DbResource) -> None:
594 """
595 Notify subscribers of resource removal.
597 Args:
598 resource: Resource to remove
599 """
600 event = {
601 "type": "resource_removed",
602 "data": {
603 "id": resource.id,
604 "uri": resource.uri,
605 "name": resource.name,
606 "is_active": False,
607 },
608 "timestamp": datetime.now(timezone.utc).isoformat(),
609 }
610 await self._publish_event(resource.uri, event)
612 async def subscribe_events(self, uri: Optional[str] = None) -> AsyncGenerator[Dict[str, Any], None]:
613 """Subscribe to resource events.
615 Args:
616 uri: Optional URI to filter events
618 Yields:
619 Resource event messages
620 """
621 queue: asyncio.Queue = asyncio.Queue()
623 if uri:
624 if uri not in self._event_subscribers:
625 self._event_subscribers[uri] = []
626 self._event_subscribers[uri].append(queue)
627 else:
628 self._event_subscribers["*"] = self._event_subscribers.get("*", [])
629 self._event_subscribers["*"].append(queue)
631 try:
632 while True:
633 event = await queue.get()
634 yield event
635 finally:
636 if uri:
637 self._event_subscribers[uri].remove(queue)
638 if not self._event_subscribers[uri]:
639 del self._event_subscribers[uri]
640 else:
641 self._event_subscribers["*"].remove(queue)
642 if not self._event_subscribers["*"]:
643 del self._event_subscribers["*"]
645 def _is_valid_uri(self, uri: str) -> bool:
646 """Validate a resource URI.
648 Args:
649 uri: URI to validate
651 Returns:
652 True if URI is valid
653 """
654 try:
655 parsed = urlparse(uri)
656 return bool(parsed.scheme and parsed.path)
657 except Exception:
658 return False
660 def _detect_mime_type(self, uri: str, content: Union[str, bytes]) -> str:
661 """Detect mime type from URI and content.
663 Args:
664 uri: Resource URI
665 content: Resource content
667 Returns:
668 Detected mime type
669 """
670 # Try from URI first
671 mime_type, _ = mimetypes.guess_type(uri)
672 if mime_type:
673 return mime_type
675 # Check content type
676 if isinstance(content, str):
677 return "text/plain"
679 return "application/octet-stream"
681 async def _read_template_resource(self, uri: str) -> ResourceContent:
682 """Read a templated resource.
684 Args:
685 uri: Template URI with parameters
687 Returns:
688 Resource content
690 Raises:
691 ResourceNotFoundError: If template not found
692 ResourceError: For other template errors
693 NotImplementedError: When binary template is passed
694 """
695 # Find matching template
696 template = None
697 for cached in self._template_cache.values():
698 if self._uri_matches_template(uri, cached.uri_template): 698 ↛ 697line 698 didn't jump to line 697 because the condition on line 698 was always true
699 template = cached
700 break
702 if not template:
703 raise ResourceNotFoundError(f"No template matches URI: {uri}")
705 try:
706 # Extract parameters
707 params = self._extract_template_params(uri, template.uri_template)
709 # Generate content
710 if template.mime_type and template.mime_type.startswith("text/"): 710 ↛ 711line 710 didn't jump to line 711 because the condition on line 710 was never true
711 content = template.uri_template.format(**params)
712 return TextContent(type="text", text=content)
714 # Handle binary template
715 raise NotImplementedError("Binary resource templates not yet supported")
717 except Exception as e:
718 raise ResourceError(f"Failed to process template: {str(e)}")
720 def _uri_matches_template(self, uri: str, template: str) -> bool:
721 """Check if URI matches a template pattern.
723 Args:
724 uri: URI to check
725 template: Template pattern
727 Returns:
728 True if URI matches template
729 """
730 # Convert template to regex pattern
732 pattern = re.escape(template).replace(r"\{.*?\}", r"[^/]+")
733 return bool(re.match(pattern, uri))
735 def _extract_template_params(self, uri: str, template: str) -> Dict[str, str]:
736 """Extract parameters from URI based on template.
738 Args:
739 uri: URI with parameter values
740 template: Template pattern
742 Returns:
743 Dict of parameter names and values
744 """
746 result = parse.parse(template, uri)
747 return result.named if result else {}
749 async def _notify_resource_added(self, resource: DbResource) -> None:
750 """
751 Notify subscribers of resource addition.
753 Args:
754 resource: Resource to add
755 """
756 event = {
757 "type": "resource_added",
758 "data": {
759 "id": resource.id,
760 "uri": resource.uri,
761 "name": resource.name,
762 "description": resource.description,
763 "is_active": resource.is_active,
764 },
765 "timestamp": datetime.now(timezone.utc).isoformat(),
766 }
767 await self._publish_event(resource.uri, event)
769 async def _notify_resource_updated(self, resource: DbResource) -> None:
770 """
771 Notify subscribers of resource update.
773 Args:
774 resource: Resource to update
775 """
776 event = {
777 "type": "resource_updated",
778 "data": {
779 "id": resource.id,
780 "uri": resource.uri,
781 "content": resource.content,
782 "is_active": resource.is_active,
783 },
784 "timestamp": datetime.now(timezone.utc).isoformat(),
785 }
786 await self._publish_event(resource.uri, event)
788 async def _publish_event(self, uri: str, event: Dict[str, Any]) -> None:
789 """Publish event to relevant subscribers.
791 Args:
792 uri: Resource URI event relates to
793 event: Event data to publish
794 """
795 # Notify resource-specific subscribers
796 if uri in self._event_subscribers: 796 ↛ 801line 796 didn't jump to line 801 because the condition on line 796 was always true
797 for queue in self._event_subscribers[uri]:
798 await queue.put(event)
800 # Notify global subscribers
801 if "*" in self._event_subscribers: 801 ↛ exitline 801 didn't return from function '_publish_event' because the condition on line 801 was always true
802 for queue in self._event_subscribers["*"]:
803 await queue.put(event)
805 # --- Resource templates ---
806 async def list_resource_templates(self, db: Session, include_inactive: bool = False) -> List[ResourceTemplate]:
807 """
808 Retrieve a list of resource templates from the database.
810 This method retrieves resource templates (resources with a defined template field) from the database
811 and converts them into a list of ResourceTemplate objects. It supports filtering out inactive templates
812 based on the include_inactive parameter. The cursor parameter is reserved for future pagination support
813 but is currently not implemented.
815 Args:
816 db (Session): The SQLAlchemy database session.
817 include_inactive (bool): If True, include inactive resource templates in the result.
818 Defaults to False.
820 Returns:
821 List[ResourceTemplate]: A list of resource templates.
822 """
823 query = select(DbResource).where(DbResource.template.isnot(None))
824 if not include_inactive: 824 ↛ 827line 824 didn't jump to line 827 because the condition on line 824 was always true
825 query = query.where(DbResource.is_active)
826 # Cursor-based pagination logic can be implemented here in the future.
827 templates = db.execute(query).scalars().all()
828 return [ResourceTemplate.model_validate(t) for t in templates]
830 # --- Metrics ---
831 async def aggregate_metrics(self, db: Session) -> ResourceMetrics:
832 """
833 Aggregate metrics for all resource invocations across all resources.
835 Args:
836 db: Database session
838 Returns:
839 ResourceMetrics: Aggregated metrics computed from all ResourceMetric records.
840 """
841 total_executions = db.execute(select(func.count()).select_from(ResourceMetric)).scalar() or 0 # pylint: disable=not-callable
843 successful_executions = db.execute(select(func.count()).select_from(ResourceMetric).where(ResourceMetric.is_success)).scalar() or 0 # pylint: disable=not-callable
845 failed_executions = db.execute(select(func.count()).select_from(ResourceMetric).where(not_(ResourceMetric.is_success))).scalar() or 0 # pylint: disable=not-callable
847 min_response_time = db.execute(select(func.min(ResourceMetric.response_time))).scalar()
849 max_response_time = db.execute(select(func.max(ResourceMetric.response_time))).scalar()
851 avg_response_time = db.execute(select(func.avg(ResourceMetric.response_time))).scalar()
853 last_execution_time = db.execute(select(func.max(ResourceMetric.timestamp))).scalar()
855 return ResourceMetrics(
856 total_executions=total_executions,
857 successful_executions=successful_executions,
858 failed_executions=failed_executions,
859 failure_rate=(failed_executions / total_executions) if total_executions > 0 else 0.0,
860 min_response_time=min_response_time,
861 max_response_time=max_response_time,
862 avg_response_time=avg_response_time,
863 last_execution_time=last_execution_time,
864 )
866 async def reset_metrics(self, db: Session) -> None:
867 """
868 Reset all resource metrics by deleting all records from the resource metrics table.
870 Args:
871 db: Database session
872 """
873 db.execute(delete(ResourceMetric))
874 db.commit()