diff --git a/pyproject.toml b/pyproject.toml index 85a3bd8..44f44e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ 'click >=8.1.0, <9.0', 'fastapi >=0.120, <1.0', 'httpx >=0.28.0, <1.0', + 'gql[httpx] >=3.4.0, <4.0', 'PyYAML >=6.0, <7.0', 'uvicorn >=0.38.0, <1.0', ] diff --git a/requirements.txt b/requirements.txt index 5bb6d8c..f99435a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,18 @@ annotated-doc==0.0.4 annotated-types==0.7.0 anyio==4.12.0 +backoff==2.2.1 certifi==2025.11.12 click==8.3.1 fastapi==0.124.4 +gql==3.5.3 +graphql-core==3.2.6 h11==0.16.0 httpcore==1.0.9 httpx==0.28.1 idna==3.11 +multidict==6.7.0 +propcache==0.4.1 pydantic==2.12.5 pydantic_core==2.41.5 PyYAML==6.0.3 @@ -17,3 +22,4 @@ typing-inspection==0.4.2 typing_extensions==4.15.0 uvicorn==0.38.0 wheel==0.45.1 +yarl==1.22.0 diff --git a/scripts/run.sh b/scripts/run.sh index d0cd006..0f1adbc 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash -uvicorn fairsharing_proxy:app --host 0.0.0.0 --port 8888 --proxy-headers --no-use-colors +uvicorn fairsharing_proxy:app --host 0.0.0.0 --port 8888 --proxy-headers --no-use-colors --reload diff --git a/src/fairsharing_proxy/api.py b/src/fairsharing_proxy/api.py index 1917d7f..0b8a28d 100644 --- a/src/fairsharing_proxy/api.py +++ b/src/fairsharing_proxy/api.py @@ -1,7 +1,10 @@ import fastapi +from typing import Annotated + from .consts import BUILD_INFO, NICE_NAME, VERSION from .core import CORE +from .model import GraphQLFastSearchQuery app = fastapi.FastAPI( title=NICE_NAME, @@ -32,6 +35,25 @@ async def post_search(request: fastapi.Request): return await CORE.search(request=request, is_get=False) +@app.get(path='/v2/search') +async def get_v2_search( + q: Annotated[str | None, fastapi.Query()] = None, + registry: Annotated[list[str] | None, fastapi.Query()] = None, + record_type: Annotated[list[str] | None, fastapi.Query()] = None, + status: Annotated[list[str] | None, fastapi.Query()] = None, + min_q: Annotated[int, fastapi.Query()] = 1, +): + if q is None or len(q) < min_q: + return [] + query = GraphQLFastSearchQuery( + q=q, + registry=registry, + record_type=record_type, + status=status, + ) + return await CORE.v2_search(query) + + @app.on_event("startup") async def app_init(): await CORE.startup() diff --git a/src/fairsharing_proxy/api_client.py b/src/fairsharing_proxy/api_client.py index 0a5154a..8a0eb5d 100644 --- a/src/fairsharing_proxy/api_client.py +++ b/src/fairsharing_proxy/api_client.py @@ -1,8 +1,11 @@ import asyncio import httpx +from gql import gql, Client +from gql.transport.httpx import HTTPXAsyncTransport + from .config import ProxyConfig -from .model import Token, Record, SearchQuery +from .model import Token, Record, SearchQuery, GraphQLFastSearchQuery _NEED_LOGIN_MESSAGE = 'please login before continuing' @@ -158,3 +161,89 @@ async def client_list_records_all( if page_delay is not None: await asyncio.sleep(page_delay) return records + + +def gql_list(values): + return "[" + ", ".join(f'"{v}"' for v in values) + "]" + + +def build_where_literal( + registries: list[str] | None = None, + types: list[str] | None = None, + statuses: list[str] | None = None, +): + filters = [] + if registries: + filters.append(f"registry: {gql_list(registries)}") + if types: + filters.append(f"type: {gql_list(types)}") + if statuses: + filters.append(f"status: {gql_list(statuses)}") + if not filters: + return """ + { + operator: "_and" + fields: [] + } + """ + inner = "\n".join(filters) + return f""" + {{ + operator: "_and" + fields: [ + {{ + operator: "_and" + {inner} + }} + ] + }} + """ + + +class FAIRSharingGraphQLClient: + + def __init__(self, cfg: ProxyConfig): + self.transport = HTTPXAsyncTransport( + url=cfg.fairsharing.graphql_api, + headers={ + 'User-Agent': 'fairsharing-proxy/0.1', + 'X-GraphQL-Key': cfg.fairsharing.graphql_key, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ) + + async def _execute(self, query, variables=None): + async with Client(transport=self.transport, + fetch_schema_from_transport=False) as client: + result = await client.execute(query, variables) + return result + + async def search(self, query: GraphQLFastSearchQuery): + where_literal = build_where_literal( + registries=query.registry, + types=query.record_type, + statuses=query.status, + ) + gquery = gql(f""" + query {{ + advancedSearchFast( + q: "{query.q}" + where: {where_literal} + ) {{ + id + type + name + homepage + abbreviation + doi + description + registry + status + }} + }} + """) + result = await self._execute( + query=gquery, + ) + return result['advancedSearchFast'] diff --git a/src/fairsharing_proxy/config.py b/src/fairsharing_proxy/config.py index cb6129b..f3ddb2e 100644 --- a/src/fairsharing_proxy/config.py +++ b/src/fairsharing_proxy/config.py @@ -13,8 +13,10 @@ def __init__(self, missing: List[str]): class FAIRSharingConfig: - def __init__(self, api: str, timeout: float): + def __init__(self, api: str, graphql_api: str, graphql_key: str, timeout: float): self.api = api + self.graphql_api = graphql_api + self.graphql_key = graphql_key self.timeout = timeout @@ -110,6 +112,8 @@ def validate(self): def _fairsharing(self): return FAIRSharingConfig( api=self.get_or_default('fairsharing', 'api'), + graphql_api=self.get_or_default('fairsharing', 'graphql_api'), + graphql_key=self.get_or_default('fairsharing', 'graphql_key'), timeout=float(self.get_or_default('fairsharing', 'timeout')), ) diff --git a/src/fairsharing_proxy/core.py b/src/fairsharing_proxy/core.py index e35c760..44182ee 100644 --- a/src/fairsharing_proxy/core.py +++ b/src/fairsharing_proxy/core.py @@ -9,10 +9,11 @@ from .config import ProxyConfig, cfg_parser from .consts import DEFAULT_CONFIG, ENV_CONFIG from .api_client import FAIRSharingClient, \ - FAIRSharingUnauthorizedError + FAIRSharingUnauthorizedError, FAIRSharingGraphQLClient from .logger import LOG, init_config_logging from .model import Token, ProxyRequest, \ - LegacySearchQuery, SearchQuery, RecordSet + LegacySearchQuery, SearchQuery, RecordSet, \ + GraphQLFastSearchQuery class SearchRetryError(Exception): @@ -75,6 +76,7 @@ def __init__(self): self.cfg = _load_config() # type: ProxyConfig self.cache = RecordsCache(cfg=self.cfg) self.client = FAIRSharingClient(cfg=self.cfg) + self.graphql = FAIRSharingGraphQLClient(cfg=self.cfg) self.token_store = TokenStore() @staticmethod @@ -205,6 +207,13 @@ async def search( content=result_set.to_json(), ) + async def v2_search(self, query: GraphQLFastSearchQuery) -> fastapi.Response: + results = await self.graphql.search(query) + return fastapi.responses.JSONResponse( + status_code=200, + content=results, + ) + async def startup(self): init_config_logging(cfg=self.cfg) diff --git a/src/fairsharing_proxy/model.py b/src/fairsharing_proxy/model.py index 0c4bc99..d31fcec 100644 --- a/src/fairsharing_proxy/model.py +++ b/src/fairsharing_proxy/model.py @@ -345,3 +345,13 @@ def to_legacy_json(self) -> dict: 'results': [r.to_legacy_json() for r in self.records], 'note': self.NOTE, } + + +class GraphQLFastSearchQuery: + + def __init__(self, q: str, registry: list[str] | None, + record_type: list[str] | None, status: list[str] | None): + self.q = q + self.registry = registry + self.record_type = record_type + self.status = status