Source code for tooluniverse.dailymed_drug_classes_tool
"""DailyMed drug classes, searchable by name.
The upstream endpoint cannot filter. `/drugclasses.json` accepts only `page`
and `pagesize`: `drug_class_name`, `class_name` and `code` each make it answer
HTTP 500 with the body `{"data": [], "metadata": {}}`, and `drug_name` and
`name` are accepted but ignored -- the response still carries all 1210 classes.
The generic REST tool passed `drug_class_name` straight through, so every
search this tool advertised returned that 500.
So the filtering happens here. The whole list is small and fixed-size: 1210
classes over 13 pages, because DailyMed caps a page at 100 however large a
`pagesize` is asked for (measured: pagesize=2000 still returns 100). Fetching
it costs 13 requests per call, which is the price of the filter the tool
advertises; `drug_class_name` is a required parameter, so there is no
unfiltered path to keep cheap.
"""
from typing import Any, Dict, List, Optional
import requests
from .base_rest_tool import BaseRESTTool
from .http_utils import request_with_retry
from .tool_registry import register_tool
# DailyMed's own ceiling, not a choice: a larger pagesize is silently clamped.
_UPSTREAM_PAGE_SIZE = 100
# A guard, not an expectation. The catalogue is 13 pages today; this stops a
# changed upstream contract from turning one call into an unbounded crawl.
_MAX_PAGES = 40
[docs]
@register_tool("DailyMedDrugClassesTool")
class DailyMedDrugClassesTool(BaseRESTTool):
"""DailyMed drug classes with the name filter applied client-side."""
@property
def _endpoint(self) -> str:
return self.tool_config["fields"]["endpoint"]
[docs]
def _fetch_page(self, page: int) -> Optional[Dict[str, Any]]:
# Go through the base class's pooled session and retry helper rather
# than a bare requests.get, so these 13 calls share the transport pool
# and the User-Agent with every other REST tool.
try:
response = request_with_retry(
self.session,
"GET",
self._endpoint,
params={"page": page, "pagesize": _UPSTREAM_PAGE_SIZE},
headers={"Accept": "application/json"},
timeout=self.timeout,
max_attempts=3,
)
except requests.exceptions.RequestException as exc:
return {"error": f"DailyMed request failed: {exc}"}
if response.status_code != 200:
return {
"error": (
f"DailyMed returned HTTP {response.status_code} for page {page}"
)
}
try:
return {"body": response.json()}
except ValueError:
return {"error": "DailyMed returned a response that is not JSON"}
[docs]
def _all_classes(self) -> Dict[str, Any]:
rows: List[Dict[str, Any]] = []
page = 1
while page <= _MAX_PAGES:
fetched = self._fetch_page(page)
if fetched is None or "error" in fetched:
return {"error": (fetched or {}).get("error", "unknown error")}
body = fetched["body"]
rows.extend(body.get("data") or [])
total_pages = (body.get("metadata") or {}).get("total_pages") or 1
if page >= total_pages:
return {"rows": rows, "pages_read": page}
page += 1
return {
"error": (
f"DailyMed reported more than {_MAX_PAGES} pages of drug classes; "
"refusing to keep paging"
)
}
[docs]
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
arguments = arguments or {}
needle = (arguments.get("drug_class_name") or "").strip()
if not needle:
# The schema makes drug_class_name required, so BaseTool rejects a
# call without it before reaching here. Reported rather than
# silently listing all 1210 classes, which is not what a caller
# that passed an empty string asked for.
return {
"status": "error",
"error": "drug_class_name is required and must not be empty",
"url": self._endpoint,
}
fetched = self._all_classes()
if "error" in fetched:
return {
"status": "error",
"error": fetched["error"],
"url": self._endpoint,
}
rows = fetched["rows"]
lowered = needle.lower()
matches = [r for r in rows if lowered in (r.get("name") or "").lower()]
try:
page = max(1, int(arguments.get("page") or 1))
except (TypeError, ValueError):
page = 1
try:
page_size = int(arguments.get("pagesize") or _UPSTREAM_PAGE_SIZE)
except (TypeError, ValueError):
page_size = _UPSTREAM_PAGE_SIZE
page_size = max(1, page_size)
start = (page - 1) * page_size
window = matches[start : start + page_size]
return {
"status": "success",
"data": window,
"metadata": {
"drug_class_name": needle,
"total_matches": len(matches),
"returned": len(window),
"page": page,
"pagesize": page_size,
"classes_searched": len(rows),
"upstream_pages_read": fetched["pages_read"],
"filtered_by": (
"ToolUniverse, case-insensitive substring of the class name: "
"DailyMed's /drugclasses.json accepts only page and pagesize "
"and answers HTTP 500 to a name filter"
),
},
"url": self._endpoint,
}