Spaces:
Running
Running
Commit ·
79879d4
1
Parent(s): faefb1f
feat: key extractor
Browse files- app/api/v1/keys_extract.py +165 -0
- app/api/v1/router.py +2 -0
- app/services/keys_extractor_service.py +97 -0
- requirements.txt +3 -0
app/api/v1/keys_extract.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import time
|
| 5 |
+
from typing import Any, Dict, List, Optional
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends
|
| 8 |
+
from pydantic import BaseModel, Field, field_validator
|
| 9 |
+
|
| 10 |
+
from app.api.deps import require_auth
|
| 11 |
+
from app.core.logger import get_logger
|
| 12 |
+
from app.core.thread_pool import thread_pool
|
| 13 |
+
from app.services.keys_extractor_service import KeysExtractor
|
| 14 |
+
|
| 15 |
+
logger = get_logger(__name__)
|
| 16 |
+
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class KeysExtractRequest(BaseModel):
|
| 21 |
+
data: Any = Field(..., description="JSON object or array to search")
|
| 22 |
+
key_names: Optional[List[str]] = Field(
|
| 23 |
+
default=None,
|
| 24 |
+
description=(
|
| 25 |
+
"Key names to look up at any depth. Mutually exclusive with `query`; "
|
| 26 |
+
"provide one of the two."
|
| 27 |
+
),
|
| 28 |
+
)
|
| 29 |
+
query: Optional[str] = Field(
|
| 30 |
+
default=None,
|
| 31 |
+
description=(
|
| 32 |
+
"Extended JSONPath expression (parsed with `jsonpath_ng.ext`) used to "
|
| 33 |
+
"select values. Supports filters, arithmetic and the union operator, e.g. "
|
| 34 |
+
"`$.store.book[?price > 10].title`. Each match is returned with its "
|
| 35 |
+
"full path inside the source document. Mutually exclusive with `key_names`."
|
| 36 |
+
),
|
| 37 |
+
)
|
| 38 |
+
result_limit: Optional[int] = Field(
|
| 39 |
+
None,
|
| 40 |
+
ge=1,
|
| 41 |
+
description=(
|
| 42 |
+
"Maximum results to return. For `key_names` mode this caps values per "
|
| 43 |
+
"key; for `query` mode it caps the number of matches returned. "
|
| 44 |
+
"Omit (or pass null) to return the full, uncapped result."
|
| 45 |
+
),
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
@field_validator("key_names")
|
| 49 |
+
@classmethod
|
| 50 |
+
def _validate_key_names(cls, v: Optional[List[str]]) -> Optional[List[str]]:
|
| 51 |
+
if v is None:
|
| 52 |
+
return v
|
| 53 |
+
if not v:
|
| 54 |
+
raise ValueError("key_names must be a non-empty list (omit it or supply a `query`)")
|
| 55 |
+
for kn in v:
|
| 56 |
+
if not isinstance(kn, str) or not kn:
|
| 57 |
+
raise ValueError("each key_name must be a non-empty string")
|
| 58 |
+
return v
|
| 59 |
+
|
| 60 |
+
@field_validator("query")
|
| 61 |
+
@classmethod
|
| 62 |
+
def _validate_query(cls, v: Optional[str]) -> Optional[str]:
|
| 63 |
+
if v is None:
|
| 64 |
+
return v
|
| 65 |
+
if not isinstance(v, str) or not v.strip():
|
| 66 |
+
raise ValueError("query must be a non-empty string")
|
| 67 |
+
return v
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class KeysExtractResponse(BaseModel):
|
| 71 |
+
success: bool
|
| 72 |
+
time_ms: float
|
| 73 |
+
data: Dict[str, Any]
|
| 74 |
+
error_message: Optional[str] = None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class KeysExtractBatchRequest(BaseModel):
|
| 78 |
+
requests: List[KeysExtractRequest] = Field(
|
| 79 |
+
...,
|
| 80 |
+
min_length=1,
|
| 81 |
+
max_length=50,
|
| 82 |
+
description="List of extraction requests (1-50) to process concurrently",
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class KeysExtractBatchResponse(BaseModel):
|
| 87 |
+
success: bool
|
| 88 |
+
time_ms: float
|
| 89 |
+
results: List[KeysExtractResponse]
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _error_response(start: float, message: str) -> KeysExtractResponse:
|
| 93 |
+
return KeysExtractResponse(
|
| 94 |
+
success=False,
|
| 95 |
+
time_ms=round((time.perf_counter() - start) * 1000, 3),
|
| 96 |
+
data={},
|
| 97 |
+
error_message=message,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
async def _extract_single(body: KeysExtractRequest) -> KeysExtractResponse:
|
| 102 |
+
start = time.perf_counter()
|
| 103 |
+
has_key_names = bool(body.key_names)
|
| 104 |
+
has_query = body.query is not None
|
| 105 |
+
|
| 106 |
+
if has_key_names and has_query:
|
| 107 |
+
return _error_response(start, "provide either `key_names` or `query`, not both")
|
| 108 |
+
if not has_key_names and not has_query:
|
| 109 |
+
return _error_response(start, "either `key_names` or `query` must be provided")
|
| 110 |
+
if not isinstance(body.data, (dict, list)):
|
| 111 |
+
return _error_response(start, "`data` must be a JSON object or array")
|
| 112 |
+
|
| 113 |
+
try:
|
| 114 |
+
loop = asyncio.get_running_loop()
|
| 115 |
+
results = await loop.run_in_executor(
|
| 116 |
+
thread_pool,
|
| 117 |
+
KeysExtractor(body.data, body.key_names, body.result_limit, body.query).extract,
|
| 118 |
+
)
|
| 119 |
+
except (TypeError, ValueError) as exc:
|
| 120 |
+
return _error_response(start, str(exc))
|
| 121 |
+
except Exception as exc:
|
| 122 |
+
return _error_response(start, f"extraction failed: {exc}")
|
| 123 |
+
|
| 124 |
+
elapsed = round((time.perf_counter() - start) * 1000, 3)
|
| 125 |
+
return KeysExtractResponse(
|
| 126 |
+
success=True,
|
| 127 |
+
time_ms=elapsed,
|
| 128 |
+
data=results,
|
| 129 |
+
error_message=None,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@router.post(
|
| 134 |
+
"/keys/extract",
|
| 135 |
+
response_model=KeysExtractBatchResponse,
|
| 136 |
+
dependencies=[Depends(require_auth)],
|
| 137 |
+
tags=["Keys Extractor"],
|
| 138 |
+
summary="Extract values from nested JSON objects (batch of up to 50 requests processed concurrently)",
|
| 139 |
+
)
|
| 140 |
+
async def extract_keys_batch(body: KeysExtractBatchRequest):
|
| 141 |
+
"""Process up to 50 extraction requests concurrently.
|
| 142 |
+
|
| 143 |
+
Each request supports two modes:
|
| 144 |
+
|
| 145 |
+
* **key_names** (default) -- recursively walk any JSON object/array and return
|
| 146 |
+
every value attached to the supplied key names, regardless of how deeply
|
| 147 |
+
nested they are. Returns ``{key_name: [values...]}``.
|
| 148 |
+
|
| 149 |
+
* **query** -- run an extended JSONPath expression and return each match with
|
| 150 |
+
its full path inside the source document. Returns
|
| 151 |
+
``{"matches": [{"path": "users.[0].role", "value": "admin"}, ...]}``.
|
| 152 |
+
"""
|
| 153 |
+
start = time.perf_counter()
|
| 154 |
+
logger.info("keys_extract_batch | start", count=len(body.requests))
|
| 155 |
+
tasks = [_extract_single(req) for req in body.requests]
|
| 156 |
+
results = await asyncio.gather(*tasks)
|
| 157 |
+
elapsed = round((time.perf_counter() - start) * 1000, 3)
|
| 158 |
+
all_ok = all(r.success for r in results)
|
| 159 |
+
logger.info(
|
| 160 |
+
"keys_extract_batch | done",
|
| 161 |
+
total=len(results),
|
| 162 |
+
succeeded=sum(1 for r in results if r.success),
|
| 163 |
+
time_ms=elapsed,
|
| 164 |
+
)
|
| 165 |
+
return KeysExtractBatchResponse(success=all_ok, time_ms=elapsed, results=results)
|
app/api/v1/router.py
CHANGED
|
@@ -12,6 +12,7 @@ from app.api.v1 import (
|
|
| 12 |
database,
|
| 13 |
embeddings,
|
| 14 |
json_extract,
|
|
|
|
| 15 |
qr_decoder,
|
| 16 |
qr_generator,
|
| 17 |
reconcile,
|
|
@@ -49,6 +50,7 @@ api_v1_router.include_router(vector_stores.router, tags=["Vector Stores"])
|
|
| 49 |
api_v1_router.include_router(webhook_socket.router, tags=["Webhook / Socket"])
|
| 50 |
api_v1_router.include_router(csv_analysis.router, tags=["CSV Analysis"])
|
| 51 |
api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
|
|
|
|
| 52 |
api_v1_router.include_router(qr_decoder.router, tags=["QR Decoder"])
|
| 53 |
api_v1_router.include_router(qr_generator.router, tags=["QR Generator"])
|
| 54 |
api_v1_router.include_router(url_shortener.router, tags=["URL Shortener"])
|
|
|
|
| 12 |
database,
|
| 13 |
embeddings,
|
| 14 |
json_extract,
|
| 15 |
+
keys_extract,
|
| 16 |
qr_decoder,
|
| 17 |
qr_generator,
|
| 18 |
reconcile,
|
|
|
|
| 50 |
api_v1_router.include_router(webhook_socket.router, tags=["Webhook / Socket"])
|
| 51 |
api_v1_router.include_router(csv_analysis.router, tags=["CSV Analysis"])
|
| 52 |
api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
|
| 53 |
+
api_v1_router.include_router(keys_extract.router, prefix="/json", tags=["Keys Extractor"])
|
| 54 |
api_v1_router.include_router(qr_decoder.router, tags=["QR Decoder"])
|
| 55 |
api_v1_router.include_router(qr_generator.router, tags=["QR Generator"])
|
| 56 |
api_v1_router.include_router(url_shortener.router, tags=["URL Shortener"])
|
app/services/keys_extractor_service.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, List, Optional
|
| 4 |
+
|
| 5 |
+
from jsonpath_ng import parse
|
| 6 |
+
from jsonpath_ng.ext import parse as ext_parse
|
| 7 |
+
from jsonpath_ng.exceptions import JsonPathLexerError, JsonPathParserError
|
| 8 |
+
|
| 9 |
+
from app.core.logger import get_logger
|
| 10 |
+
|
| 11 |
+
logger = get_logger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class KeysExtractor:
|
| 15 |
+
"""Nested JSON extractor using jsonpath-ng.
|
| 16 |
+
|
| 17 |
+
Supports two modes:
|
| 18 |
+
1. key_names — recursive lookup by key name at any depth
|
| 19 |
+
2. query — extended JSONPath expression with full path output
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
def __init__(
|
| 23 |
+
self,
|
| 24 |
+
data: dict | list,
|
| 25 |
+
key_names: Optional[List[str]] = None,
|
| 26 |
+
result_limit: Optional[int] = 50,
|
| 27 |
+
query: Optional[str] = None,
|
| 28 |
+
):
|
| 29 |
+
if query is None and not key_names:
|
| 30 |
+
raise ValueError("either `query` or `key_names` must be provided")
|
| 31 |
+
|
| 32 |
+
if key_names is not None:
|
| 33 |
+
if not isinstance(key_names, list):
|
| 34 |
+
raise TypeError("key_names must be a list")
|
| 35 |
+
for kn in key_names:
|
| 36 |
+
if not isinstance(kn, str) or not kn:
|
| 37 |
+
raise ValueError(f"each key_name must be a non-empty string, got {kn!r}")
|
| 38 |
+
|
| 39 |
+
if query is not None:
|
| 40 |
+
if not isinstance(query, str) or not query.strip():
|
| 41 |
+
raise ValueError("query must be a non-empty string")
|
| 42 |
+
|
| 43 |
+
if result_limit is not None and result_limit < 1:
|
| 44 |
+
raise ValueError("result_limit must be >= 1 or None")
|
| 45 |
+
|
| 46 |
+
self._data = data
|
| 47 |
+
self._key_names = key_names or []
|
| 48 |
+
self._result_limit = result_limit
|
| 49 |
+
self._query = query
|
| 50 |
+
|
| 51 |
+
def extract(self) -> dict[str, Any]:
|
| 52 |
+
if self._query is not None:
|
| 53 |
+
return {"matches": self._extract_by_query()}
|
| 54 |
+
return {k: self._extract_single(k) for k in self._key_names}
|
| 55 |
+
|
| 56 |
+
def _extract_by_query(self) -> List[dict[str, Any]]:
|
| 57 |
+
try:
|
| 58 |
+
expr = ext_parse(self._query)
|
| 59 |
+
except (JsonPathLexerError, JsonPathParserError) as exc:
|
| 60 |
+
raise ValueError(f"invalid jsonpath query {self._query!r}: {exc}") from exc
|
| 61 |
+
|
| 62 |
+
matches: List[dict[str, Any]] = [
|
| 63 |
+
{"path": str(m.full_path), "value": m.value}
|
| 64 |
+
for m in expr.find(self._data)
|
| 65 |
+
]
|
| 66 |
+
|
| 67 |
+
if self._result_limit is not None:
|
| 68 |
+
matches = matches[: self._result_limit]
|
| 69 |
+
|
| 70 |
+
return matches
|
| 71 |
+
|
| 72 |
+
def _extract_single(self, key_name: str) -> List[Any]:
|
| 73 |
+
try:
|
| 74 |
+
expr = _build_jp_expr(key_name)
|
| 75 |
+
except ValueError:
|
| 76 |
+
return []
|
| 77 |
+
vals = [m.value for m in expr.find(self._data)]
|
| 78 |
+
if self._result_limit is not None:
|
| 79 |
+
vals = vals[: self._result_limit]
|
| 80 |
+
return vals
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _build_jp_expr(key_name: str) -> Any:
|
| 84 |
+
if '"' in key_name and "'" in key_name:
|
| 85 |
+
raise ValueError(f"key contains both quote types: {key_name!r}")
|
| 86 |
+
if '"' in key_name:
|
| 87 |
+
expr_str = "$..['" + key_name + "']"
|
| 88 |
+
else:
|
| 89 |
+
expr_str = '$..["' + key_name + '"]'
|
| 90 |
+
try:
|
| 91 |
+
return parse(expr_str)
|
| 92 |
+
except (JsonPathLexerError, JsonPathParserError):
|
| 93 |
+
raise ValueError(f"unable to build jsonpath expression for key: {key_name!r}")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def get_nested_values(data: dict | list, key_name: str) -> List[Any]:
|
| 97 |
+
return KeysExtractor(data, key_names=[key_name], result_limit=None).extract()[key_name]
|
requirements.txt
CHANGED
|
@@ -62,5 +62,8 @@ Unidecode>=1.3.8
|
|
| 62 |
# JSON Schema validation
|
| 63 |
jsonschema>=4.21.0
|
| 64 |
|
|
|
|
|
|
|
|
|
|
| 65 |
# QR code generation (testing)
|
| 66 |
qrcode[pil]>=8.0
|
|
|
|
| 62 |
# JSON Schema validation
|
| 63 |
jsonschema>=4.21.0
|
| 64 |
|
| 65 |
+
# JSONPath extraction
|
| 66 |
+
jsonpath-ng>=1.7.0
|
| 67 |
+
|
| 68 |
# QR code generation (testing)
|
| 69 |
qrcode[pil]>=8.0
|