diff --git a/README.md b/README.md index 1b152e6..8c7d47a 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Use query parameters: | Parameter | Description | | ----------- | ----------- | | **`url`** | Required. Address of the JSON document (`http` or `https`). Must be URL-encoded in the query string. | -| **`query`** | Required. [JSONPath](https://goessner.net/articles/JsonPath/) (e.g. `$.items[0].metrics.pct`) or dot form from the root (e.g. `items.0.metrics.pct`). | +| **`query`** | Required. [JSONPath](https://goessner.net/articles/JsonPath/) (e.g. `$.items[0].metrics.pct`) or dot form from the root (e.g. `items.0.metrics.pct`). Filter expressions are supported, so you can look up a value by a sibling field instead of a hardcoded index — e.g. `$.progress[?(@.data.language.name=='Spanish')].data.translationProgress`. | | **`cache`** | Optional. `Cache-Control` max-age in seconds. Default: none (header not set unless provided and `> 0`). | Any other parameters from the table above (`title`, `scale`, `style`, …) apply the same way as on `/{number}/`. diff --git a/app.py b/app.py index 2c30030..afca386 100644 --- a/app.py +++ b/app.py @@ -8,8 +8,8 @@ from urllib.request import HTTPRedirectHandler, Request, build_opener from flask import Flask, make_response, redirect, render_template, request -from jsonpath_ng import parse from jsonpath_ng.exceptions import JsonPathParserError +from jsonpath_ng.ext import parse app = Flask(__name__) @@ -100,7 +100,13 @@ def normalize_jsonpath_query(selector): """ Normalize a path selector into jsonpath-ng syntax or dot form from the root. - Examples: ``$.items[0].metrics.pct`` or ``items.0.metrics.pct``. + Accepts full JSONPath (including filter expressions) when prefixed with ``$``, + or a simple dot/index form from the root. + + Examples: + - ``$.items[0].metrics.pct`` + - ``items.0.metrics.pct`` + - ``$.progress[?(@.data.language.name=='Spanish')].data.translationProgress`` """ q = (selector or "").strip() if not q: @@ -439,8 +445,9 @@ def get_progress_svg_dynamic_json(): """ Load progress from a remote JSON `url` and a `query` into that document. - `query` is a jsonpath-ng expression (e.g. $.items[0].metrics.pct) or dot form - from the root (e.g. items.0.metrics.pct). + `query` is a jsonpath-ng expression (e.g. $.items[0].metrics.pct), a dot form + from the root (e.g. items.0.metrics.pct), or a filter expression + (e.g. $.progress[?(@.data.language.name=='Spanish')].data.translationProgress). Optional `cache` sets Cache-Control max-age in seconds. Other params match /N/ (title, scale, width, style, …). """ diff --git a/test_api.py b/test_api.py index 15fa8c1..c7e6084 100644 --- a/test_api.py +++ b/test_api.py @@ -97,6 +97,42 @@ def test_dynamic_json_jsonpath_dollar(mock_fetch, client): assert b"42" in response.data +@patch("app.fetch_json_document") +def test_dynamic_json_filter_expression_by_name(mock_fetch, client): + mock_fetch.return_value = { + "progress": [ + {"data": {"language": {"name": "Arabic"}, "translationProgress": 5}}, + {"data": {"language": {"name": "Spanish"}, "translationProgress": 87}}, + ], + } + response = client.get( + "/dynamic/json/", + query_string={ + "url": "https://example.com/stats.json", + "query": "$.progress[?(@.data.language.name=='Spanish')].data.translationProgress", + }, + ) + assert response.status_code == 200 + assert b"87" in response.data + + +@patch("app.fetch_json_document") +def test_dynamic_json_filter_expression_no_match_returns_422(mock_fetch, client): + mock_fetch.return_value = { + "progress": [ + {"data": {"language": {"name": "Arabic"}, "translationProgress": 5}}, + ], + } + response = client.get( + "/dynamic/json/", + query_string={ + "url": "https://example.com/stats.json", + "query": "$.progress[?(@.data.language.name=='Klingon')].data.translationProgress", + }, + ) + assert response.status_code == 422 + + @patch("app.fetch_json_document") def test_dynamic_json_percent_suffix_string(mock_fetch, client): mock_fetch.return_value = {"approvalProgress": "73%"}