Upload 5 files
Browse files- src/api/search.py +162 -60
src/api/search.py
CHANGED
|
@@ -3,14 +3,16 @@ import asyncio
|
|
| 3 |
import logging
|
| 4 |
import re
|
| 5 |
from typing import Optional
|
| 6 |
-
from urllib.parse import quote
|
| 7 |
|
| 8 |
from fastapi import APIRouter
|
| 9 |
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
| 10 |
|
| 11 |
logger = logging.getLogger(__name__)
|
| 12 |
router = APIRouter()
|
| 13 |
-
|
|
|
|
|
|
|
| 14 |
PUBMED_REQUEST_TIMEOUT = 8.0
|
| 15 |
|
| 16 |
|
|
@@ -19,7 +21,9 @@ class SearchRequest(BaseModel):
|
|
| 19 |
query: str = Field(min_length=1, max_length=1000)
|
| 20 |
boolean_query: Optional[str] = Field(default=None, max_length=1500)
|
| 21 |
source: str = Field(default="primo", pattern=r"^(primo|pubmed)$")
|
| 22 |
-
limit: int = Field(default=
|
|
|
|
|
|
|
| 23 |
peer_reviewed: bool = False
|
| 24 |
open_access: bool = False
|
| 25 |
year_from: Optional[str] = Field(default=None, pattern=r"^[0-9]{4}$")
|
|
@@ -41,9 +45,11 @@ class SearchRequest(BaseModel):
|
|
| 41 |
return self
|
| 42 |
|
| 43 |
|
| 44 |
-
def _boolean_to_primo_params(boolean_query: str) -> str:
|
| 45 |
"""Preserve exact phrases and all Boolean operators in one Primo q value."""
|
| 46 |
-
|
|
|
|
|
|
|
| 47 |
|
| 48 |
|
| 49 |
def _year_bounds(req: SearchRequest) -> tuple[str, str]:
|
|
@@ -51,10 +57,15 @@ def _year_bounds(req: SearchRequest) -> tuple[str, str]:
|
|
| 51 |
return req.year_from or "0001", req.year_to or "9999"
|
| 52 |
|
| 53 |
|
| 54 |
-
def _build_primo_search_link(title: str, record_id: Optional[str] = None
|
|
|
|
| 55 |
base = "https://khalifa.primo.exlibrisgroup.com/discovery"
|
| 56 |
if record_id:
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
clean_title = (title or "").strip()
|
| 59 |
if not clean_title:
|
| 60 |
return None
|
|
@@ -74,17 +85,105 @@ def _first(value, default=""):
|
|
| 74 |
def _result_link(raw_link: str, fallback: Optional[str]) -> Optional[str]:
|
| 75 |
# PNX link fields can contain $$U<url>$$D<label>, rather than a bare URL.
|
| 76 |
match = re.match(r"(?:\$\$U)?(https?://[^\s]+?)(?:\$\$|$)", raw_link)
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
"""Search Primo using the full preferred query and documented facet syntax."""
|
| 82 |
import httpx
|
| 83 |
|
| 84 |
if not api_key:
|
| 85 |
-
return
|
| 86 |
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
facets = []
|
| 89 |
if req.peer_reviewed:
|
| 90 |
facets.append("facet_tlevel,exact,peer_reviewed")
|
|
@@ -96,75 +195,77 @@ async def search_primo(req: SearchRequest, api_key: str) -> dict:
|
|
| 96 |
if req.resource_type != "both":
|
| 97 |
facets.append(f"facet_rtype,exact,{req.resource_type}")
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
# qInclude accepts one value, joining ANDed facets with |,|. Repeated
|
| 100 |
# qInclude keys are not its documented API contract.
|
| 101 |
facet_params = f"&qInclude={quote('|,|'.join(facets), safe='')}" if facets else ""
|
| 102 |
qs = (
|
| 103 |
"?vid=971KUOSTAR_INST:KU&tab=Everything&scope=MyInst_and_CI"
|
| 104 |
-
f"&{query_params}&lang=en&sort=
|
| 105 |
-
f"{
|
|
|
|
| 106 |
)
|
| 107 |
|
|
|
|
|
|
|
| 108 |
async with httpx.AsyncClient(timeout=PRIMO_REQUEST_TIMEOUT) as client:
|
| 109 |
-
for
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
| 111 |
try:
|
| 112 |
# httpx read timeouts reset per chunk. wait_for additionally
|
| 113 |
# bounds the complete request, including a slowly streamed body.
|
| 114 |
response = await asyncio.wait_for(
|
| 115 |
client.get(url, headers={"Accept": "application/json", "Authorization": f"apikey {api_key}"}),
|
| 116 |
-
timeout=
|
| 117 |
)
|
| 118 |
if response.status_code != 200:
|
| 119 |
-
|
|
|
|
| 120 |
if response.status_code == 429:
|
| 121 |
-
return
|
| 122 |
if response.status_code == 400:
|
| 123 |
-
return
|
| 124 |
-
|
|
|
|
|
|
|
| 125 |
data = response.json()
|
| 126 |
if not isinstance(data, dict) or not isinstance(data.get("docs"), list):
|
| 127 |
raise ValueError("Invalid PRIMO response")
|
| 128 |
-
results =
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
raw_link = _first(links.get("openurl") or links.get("linktorsrc"))
|
| 147 |
-
results.append({
|
| 148 |
-
"record_id": record_id,
|
| 149 |
-
"title": title,
|
| 150 |
-
"creator": "; ".join(str(creator) for creator in creators) or "Unknown",
|
| 151 |
-
"date": _first(search.get("creationdate") or addata.get("risdate") or addata.get("date")),
|
| 152 |
-
"type": _first(display.get("type")),
|
| 153 |
-
"source": _first(display.get("source") or addata.get("jtitle")),
|
| 154 |
-
"description": _first(display.get("description"))[:400],
|
| 155 |
-
"doi": _first(addata.get("doi"), None),
|
| 156 |
-
"primo_url": primo_url,
|
| 157 |
-
"link": _result_link(raw_link, primo_url),
|
| 158 |
-
"open_access": _first(display.get("oa")) == "free_for_read",
|
| 159 |
-
"_source": "PRIMO",
|
| 160 |
-
})
|
| 161 |
-
return {"total": (data.get("info") or {}).get("total", 0), "results": results, "source": "PRIMO"}
|
| 162 |
-
except (httpx.HTTPError, TimeoutError, ValueError, TypeError, AttributeError) as exc:
|
| 163 |
# Exception strings from HTTP clients can include the full URL,
|
| 164 |
# including the API key. Never put them in logs or responses.
|
| 165 |
-
logger.warning("PRIMO
|
| 166 |
|
| 167 |
-
|
|
|
|
|
|
|
| 168 |
|
| 169 |
|
| 170 |
def _pubmed_query(req: SearchRequest) -> str:
|
|
@@ -268,4 +369,5 @@ async def search_endpoint(req: SearchRequest):
|
|
| 268 |
|
| 269 |
if req.source == "pubmed":
|
| 270 |
return await search_pubmed(req)
|
| 271 |
-
|
|
|
|
|
|
| 3 |
import logging
|
| 4 |
import re
|
| 5 |
from typing import Optional
|
| 6 |
+
from urllib.parse import quote, urlsplit
|
| 7 |
|
| 8 |
from fastapi import APIRouter
|
| 9 |
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
| 10 |
|
| 11 |
logger = logging.getLogger(__name__)
|
| 12 |
router = APIRouter()
|
| 13 |
+
# One deadline covers all gateways, rather than one wait per region. A region
|
| 14 |
+
# selected in HF's PRIMO_API_REGION avoids even the fast discovery attempts.
|
| 15 |
+
PRIMO_REQUEST_TIMEOUT = 6.0
|
| 16 |
PUBMED_REQUEST_TIMEOUT = 8.0
|
| 17 |
|
| 18 |
|
|
|
|
| 21 |
query: str = Field(min_length=1, max_length=1000)
|
| 22 |
boolean_query: Optional[str] = Field(default=None, max_length=1500)
|
| 23 |
source: str = Field(default="primo", pattern=r"^(primo|pubmed)$")
|
| 24 |
+
limit: int = Field(default=10, ge=1, le=20)
|
| 25 |
+
sort: str = Field(default="rank", pattern=r"^(rank|date_d|date_a)$")
|
| 26 |
+
query_field: str = Field(default="any", pattern=r"^(any|title|creator|sub)$")
|
| 27 |
peer_reviewed: bool = False
|
| 28 |
open_access: bool = False
|
| 29 |
year_from: Optional[str] = Field(default=None, pattern=r"^[0-9]{4}$")
|
|
|
|
| 45 |
return self
|
| 46 |
|
| 47 |
|
| 48 |
+
def _boolean_to_primo_params(boolean_query: str, query_field: str = "any") -> str:
|
| 49 |
"""Preserve exact phrases and all Boolean operators in one Primo q value."""
|
| 50 |
+
if query_field not in {"any", "title", "creator", "sub"}:
|
| 51 |
+
raise ValueError("Unsupported PRIMO search field")
|
| 52 |
+
return f"q={query_field},contains,{quote(boolean_query.strip(), safe='')}"
|
| 53 |
|
| 54 |
|
| 55 |
def _year_bounds(req: SearchRequest) -> tuple[str, str]:
|
|
|
|
| 57 |
return req.year_from or "0001", req.year_to or "9999"
|
| 58 |
|
| 59 |
|
| 60 |
+
def _build_primo_search_link(title: str, record_id: Optional[str] = None,
|
| 61 |
+
context: Optional[str] = None) -> Optional[str]:
|
| 62 |
base = "https://khalifa.primo.exlibrisgroup.com/discovery"
|
| 63 |
if record_id:
|
| 64 |
+
context_param = f"&context={context}" if isinstance(context, str) and context in {"L", "PC"} else ""
|
| 65 |
+
return (
|
| 66 |
+
f"{base}/fulldisplay?docid={quote(str(record_id), safe='')}&vid=971KUOSTAR_INST:KU"
|
| 67 |
+
f"{context_param}&lang=en&tab=Everything&search_scope=MyInst_and_CI"
|
| 68 |
+
)
|
| 69 |
clean_title = (title or "").strip()
|
| 70 |
if not clean_title:
|
| 71 |
return None
|
|
|
|
| 85 |
def _result_link(raw_link: str, fallback: Optional[str]) -> Optional[str]:
|
| 86 |
# PNX link fields can contain $$U<url>$$D<label>, rather than a bare URL.
|
| 87 |
match = re.match(r"(?:\$\$U)?(https?://[^\s]+?)(?:\$\$|$)", raw_link)
|
| 88 |
+
if not match:
|
| 89 |
+
return fallback
|
| 90 |
+
candidate = match.group(1)
|
| 91 |
+
try:
|
| 92 |
+
parsed = urlsplit(candidate)
|
| 93 |
+
if parsed.hostname and not parsed.username and not parsed.password:
|
| 94 |
+
return candidate
|
| 95 |
+
except ValueError:
|
| 96 |
+
pass
|
| 97 |
+
return fallback
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _pnx_section(pnx: dict, section: str) -> dict:
|
| 101 |
+
value = pnx.get(section)
|
| 102 |
+
return value if isinstance(value, dict) else {}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _primo_records(data: dict, limit: int) -> list[dict]:
|
| 106 |
+
"""Normalize returned PNX metadata; never infer circulation availability."""
|
| 107 |
+
results = []
|
| 108 |
+
seen = set()
|
| 109 |
+
for doc in data.get("docs", []):
|
| 110 |
+
if not isinstance(doc, dict) or not isinstance(doc.get("pnx"), dict):
|
| 111 |
+
continue
|
| 112 |
+
pnx = doc["pnx"]
|
| 113 |
+
display = _pnx_section(pnx, "display")
|
| 114 |
+
addata = _pnx_section(pnx, "addata")
|
| 115 |
+
search = _pnx_section(pnx, "search")
|
| 116 |
+
sort = _pnx_section(pnx, "sort")
|
| 117 |
+
control = _pnx_section(pnx, "control")
|
| 118 |
+
links = _pnx_section(pnx, "links")
|
| 119 |
+
record_id = _first(control.get("recordid"), None) or _first(doc.get("id"), None)
|
| 120 |
+
title = _first(display.get("title")) or _first(addata.get("btitle") or addata.get("atitle"))
|
| 121 |
+
# A corrupt/empty item is not a real result to pad the requested count.
|
| 122 |
+
if not title:
|
| 123 |
+
continue
|
| 124 |
+
if record_id and record_id in seen:
|
| 125 |
+
continue
|
| 126 |
+
if record_id:
|
| 127 |
+
seen.add(record_id)
|
| 128 |
+
primo_url = _build_primo_search_link(title, record_id, _first(doc.get("context")))
|
| 129 |
+
creators = display.get("creator") or display.get("contributor") or addata.get("au") or []
|
| 130 |
+
if isinstance(creators, str):
|
| 131 |
+
creators = [creators]
|
| 132 |
+
if not isinstance(creators, list):
|
| 133 |
+
creators = []
|
| 134 |
+
raw_link = _first(links.get("openurl") or links.get("linktorsrc"))
|
| 135 |
+
results.append({
|
| 136 |
+
"record_id": record_id,
|
| 137 |
+
"title": title,
|
| 138 |
+
"creator": "; ".join(creator for creator in creators if isinstance(creator, str)),
|
| 139 |
+
"date": (_first(search.get("creationdate")) or _first(addata.get("risdate"))
|
| 140 |
+
or _first(addata.get("date")) or _first(sort.get("creationdate"))),
|
| 141 |
+
"type": _first(display.get("type") or addata.get("ristype")),
|
| 142 |
+
"source": _first(display.get("source") or addata.get("jtitle")),
|
| 143 |
+
"description": _first(display.get("description"))[:400],
|
| 144 |
+
"doi": _first(addata.get("doi"), None),
|
| 145 |
+
"primo_url": primo_url,
|
| 146 |
+
"link": _result_link(raw_link, primo_url),
|
| 147 |
+
"open_access": True if _first(display.get("oa")) == "free_for_read" else None,
|
| 148 |
+
"_source": "PRIMO",
|
| 149 |
+
})
|
| 150 |
+
if len(results) >= limit:
|
| 151 |
+
break
|
| 152 |
+
return results
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _primo_failure(message: str, code: str, status: str = "unavailable") -> dict:
|
| 156 |
+
return {"error": message, "error_code": code, "status": status,
|
| 157 |
+
"results": [], "total": 0, "source": "PRIMO"}
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _primo_gateways(region: str) -> tuple[str, ...]:
|
| 161 |
+
"""Allow only Ex Libris's documented gateway hosts; no custom secret target."""
|
| 162 |
+
hosts = {
|
| 163 |
+
"eu": "api-eu.hosted.exlibrisgroup.com",
|
| 164 |
+
"na": "api-na.hosted.exlibrisgroup.com",
|
| 165 |
+
"ap": "api-ap.hosted.exlibrisgroup.com",
|
| 166 |
+
"ca": "api-ca.hosted.exlibrisgroup.com",
|
| 167 |
+
"aps": "api-aps.hosted.exlibrisgroup.com",
|
| 168 |
+
"cn": "api-cn.hosted.exlibrisgroup.com.cn",
|
| 169 |
+
}
|
| 170 |
+
if region == "auto":
|
| 171 |
+
return tuple(hosts[name] for name in ("eu", "na", "ap"))
|
| 172 |
+
return (hosts[region],) if region in hosts else ()
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
async def search_primo(req: SearchRequest, api_key: str, region: str = "auto") -> dict:
|
| 176 |
"""Search Primo using the full preferred query and documented facet syntax."""
|
| 177 |
import httpx
|
| 178 |
|
| 179 |
if not api_key:
|
| 180 |
+
return _primo_failure("PRIMO results are not configured", "not_configured", "not_configured")
|
| 181 |
|
| 182 |
+
gateways = _primo_gateways(region)
|
| 183 |
+
if not gateways:
|
| 184 |
+
return _primo_failure("PRIMO API region is not configured correctly", "invalid_region", "not_configured")
|
| 185 |
+
|
| 186 |
+
query_params = _boolean_to_primo_params(req.boolean_query or req.query, req.query_field)
|
| 187 |
facets = []
|
| 188 |
if req.peer_reviewed:
|
| 189 |
facets.append("facet_tlevel,exact,peer_reviewed")
|
|
|
|
| 195 |
if req.resource_type != "both":
|
| 196 |
facets.append(f"facet_rtype,exact,{req.resource_type}")
|
| 197 |
|
| 198 |
+
# Multiple qInclude values in the same category are ANDed, so an explicit
|
| 199 |
+
# Books OR Articles request must use multiFacets for that category.
|
| 200 |
+
mixed_types = "facet_rtype,include,books|,|facet_rtype,include,articles"
|
| 201 |
+
mixed_params = f"&multiFacets={quote(mixed_types, safe='')}" if req.resource_type == "both" else ""
|
| 202 |
+
compact_query = re.sub(r'["\s-]', "", (req.boolean_query or req.query)).upper()
|
| 203 |
+
exact_identifier = bool(re.fullmatch(r"(?:[0-9]{9}[0-9X]|[0-9]{13})", compact_query))
|
| 204 |
+
controlled_vocabulary = "false" if req.query_field == "title" or exact_identifier else "true"
|
| 205 |
+
|
| 206 |
# qInclude accepts one value, joining ANDed facets with |,|. Repeated
|
| 207 |
# qInclude keys are not its documented API contract.
|
| 208 |
facet_params = f"&qInclude={quote('|,|'.join(facets), safe='')}" if facets else ""
|
| 209 |
qs = (
|
| 210 |
"?vid=971KUOSTAR_INST:KU&tab=Everything&scope=MyInst_and_CI"
|
| 211 |
+
f"&{query_params}&lang=en&sort={req.sort}&limit={req.limit}&offset=0"
|
| 212 |
+
f"&conVoc={controlled_vocabulary}&skipDelivery=true"
|
| 213 |
+
f"{facet_params}{mixed_params}"
|
| 214 |
)
|
| 215 |
|
| 216 |
+
deadline = asyncio.get_running_loop().time() + PRIMO_REQUEST_TIMEOUT
|
| 217 |
+
last_status = None
|
| 218 |
async with httpx.AsyncClient(timeout=PRIMO_REQUEST_TIMEOUT) as client:
|
| 219 |
+
for gateway in gateways:
|
| 220 |
+
remaining = deadline - asyncio.get_running_loop().time()
|
| 221 |
+
if remaining <= 0:
|
| 222 |
+
return _primo_failure("PRIMO took too long to respond; use the search link", "timeout")
|
| 223 |
+
url = f"https://{gateway}/primo/v1/search{qs}"
|
| 224 |
try:
|
| 225 |
# httpx read timeouts reset per chunk. wait_for additionally
|
| 226 |
# bounds the complete request, including a slowly streamed body.
|
| 227 |
response = await asyncio.wait_for(
|
| 228 |
client.get(url, headers={"Accept": "application/json", "Authorization": f"apikey {api_key}"}),
|
| 229 |
+
timeout=remaining,
|
| 230 |
)
|
| 231 |
if response.status_code != 200:
|
| 232 |
+
last_status = response.status_code
|
| 233 |
+
logger.warning("PRIMO gateway %s returned HTTP %s", gateway, response.status_code)
|
| 234 |
if response.status_code == 429:
|
| 235 |
+
return _primo_failure("PRIMO request limit reached; try again later", "rate_limited", "rate_limited")
|
| 236 |
if response.status_code == 400:
|
| 237 |
+
return _primo_failure("PRIMO could not process this search; check the search terms", "invalid_query", "error")
|
| 238 |
+
if response.status_code in (401, 403, 404):
|
| 239 |
+
continue
|
| 240 |
+
return _primo_failure("PRIMO API unavailable", "upstream_error")
|
| 241 |
data = response.json()
|
| 242 |
if not isinstance(data, dict) or not isinstance(data.get("docs"), list):
|
| 243 |
raise ValueError("Invalid PRIMO response")
|
| 244 |
+
results = _primo_records(data, req.limit)
|
| 245 |
+
info = data.get("info") if isinstance(data.get("info"), dict) else {}
|
| 246 |
+
raw_total = info.get("total")
|
| 247 |
+
total = None
|
| 248 |
+
# Unknown totals must stay unknown: a short page is not evidence
|
| 249 |
+
# that those records exhaust all matches in the catalogue.
|
| 250 |
+
if ((type(raw_total) is int) or
|
| 251 |
+
(isinstance(raw_total, str) and re.fullmatch(r"[0-9]+", raw_total.strip()))):
|
| 252 |
+
parsed_total = int(raw_total)
|
| 253 |
+
if parsed_total >= len(results):
|
| 254 |
+
total = parsed_total
|
| 255 |
+
if data["docs"] and not results:
|
| 256 |
+
return _primo_failure("PRIMO returned unreadable record details; use the search link", "invalid_response")
|
| 257 |
+
return {"total": total, "results": results, "source": "PRIMO", "status": "ok"}
|
| 258 |
+
except (TimeoutError, httpx.TimeoutException):
|
| 259 |
+
logger.warning("PRIMO search exceeded the total request deadline")
|
| 260 |
+
return _primo_failure("PRIMO took too long to respond; use the search link", "timeout")
|
| 261 |
+
except (httpx.HTTPError, ValueError, TypeError, AttributeError) as exc:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
# Exception strings from HTTP clients can include the full URL,
|
| 263 |
# including the API key. Never put them in logs or responses.
|
| 264 |
+
logger.warning("PRIMO gateway %s failed (%s)", gateway, type(exc).__name__)
|
| 265 |
|
| 266 |
+
if last_status in (401, 403):
|
| 267 |
+
return _primo_failure("PRIMO access could not be verified; use the search link", "unauthorized")
|
| 268 |
+
return _primo_failure("PRIMO API unavailable", "upstream_error")
|
| 269 |
|
| 270 |
|
| 271 |
def _pubmed_query(req: SearchRequest) -> str:
|
|
|
|
| 369 |
|
| 370 |
if req.source == "pubmed":
|
| 371 |
return await search_pubmed(req)
|
| 372 |
+
settings = get_settings()
|
| 373 |
+
return await search_primo(req, settings.primo_api_key, settings.primo_api_region)
|