From e8ff9f9196c5d54203eed13d889f8b051e1bb14b Mon Sep 17 00:00:00 2001 From: Nachiket Date: Mon, 25 May 2026 05:01:37 -0700 Subject: [PATCH 1/6] feat: add FastAPI anonymizer server --- presidio-anonymizer/README.md | 11 ++ presidio-anonymizer/fastapi_app.py | 139 ++++++++++++++++++ presidio-anonymizer/pyproject.toml | 4 + presidio-anonymizer/tests/test_fastapi_app.py | 74 ++++++++++ 4 files changed, 228 insertions(+) create mode 100644 presidio-anonymizer/fastapi_app.py create mode 100644 presidio-anonymizer/tests/test_fastapi_app.py diff --git a/presidio-anonymizer/README.md b/presidio-anonymizer/README.md index bba95d2abf..afa39cf979 100644 --- a/presidio-anonymizer/README.md +++ b/presidio-anonymizer/README.md @@ -202,3 +202,14 @@ docker-compose up -d Follow the [API Spec](https://microsoft.github.io/presidio/api-docs/api-docs.html#tag/Anonymizer) for the Anonymizer REST API reference details + +#### Optional FastAPI server + +The anonymizer package also includes a FastAPI server with the same REST endpoints +as the default Flask server. Install the optional dependencies and run it with +Uvicorn: + +```sh +pip install "presidio-anonymizer[fastapi]" +uvicorn fastapi_app:app --host 0.0.0.0 --port 3000 +``` diff --git a/presidio-anonymizer/fastapi_app.py b/presidio-anonymizer/fastapi_app.py new file mode 100644 index 0000000000..7f203d9890 --- /dev/null +++ b/presidio-anonymizer/fastapi_app.py @@ -0,0 +1,139 @@ +"""FastAPI REST API server for anonymizer.""" + +import logging +import os +from logging.config import fileConfig +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse, Response +from presidio_anonymizer import AnonymizerEngine, DeanonymizeEngine +from presidio_anonymizer.entities import InvalidParamError +from presidio_anonymizer.services.app_entities_convertor import AppEntitiesConvertor + +DEFAULT_PORT = "3000" + +LOGGING_CONF_FILE = "logging.ini" + +WELCOME_MESSAGE = r""" + _______ _______ _______ _______ _________ ______ _________ _______ +( ____ )( ____ )( ____ \( ____ \\__ __/( __ \ \__ __/( ___ ) +| ( )|| ( )|| ( \/| ( \/ ) ( | ( \ ) ) ( | ( ) | +| (____)|| (____)|| (__ | (_____ | | | | ) | | | | | | | +| _____)| __)| __) (_____ ) | | | | | | | | | | | | +| ( | (\ ( | ( ) | | | | | ) | | | | | | | +| ) | ) \ \__| (____/\/\____) |___) (___| (__/ )___) (___| (___) | +|/ |/ \__/(_______/\_______)\_______/(______/ \_______/(_______) +""" + + +class Server: + """FastAPI server for anonymizer.""" + + def __init__(self) -> None: + fileConfig(Path(Path(__file__).parent, LOGGING_CONF_FILE)) + self.logger = logging.getLogger("presidio-anonymizer") + self.logger.setLevel(os.environ.get("LOG_LEVEL", self.logger.level)) + self.app = FastAPI(title="Presidio Anonymizer") + self.logger.info("Starting anonymizer engine") + self.anonymizer = AnonymizerEngine() + self.deanonymize = DeanonymizeEngine() + self.logger.info(WELCOME_MESSAGE) + self._add_routes() + self._add_error_handlers() + + def _add_routes(self) -> None: + @self.app.get("/health", response_class=Response) + def health() -> str: + """Return basic health probe result.""" + return "Presidio Anonymizer service is up" + + @self.app.post("/anonymize") + def anonymize(content: dict[str, Any]) -> Response: + if not content: + raise HTTPException(status_code=400, detail="Invalid request json") + + anonymizers_config = AppEntitiesConvertor.operators_config_from_json( + content.get("anonymizers") + ) + if AppEntitiesConvertor.check_custom_operator(anonymizers_config): + raise HTTPException( + status_code=400, detail="Custom type anonymizer is not supported" + ) + + analyzer_results = AppEntitiesConvertor.analyzer_results_from_json( + content.get("analyzer_results") + ) + anonymizer_result = self.anonymizer.anonymize( + text=content.get("text", ""), + analyzer_results=analyzer_results, + operators=anonymizers_config, + ) + return Response( + content=anonymizer_result.to_json(), media_type="application/json" + ) + + @self.app.post("/deanonymize") + def deanonymize(content: dict[str, Any]) -> Response: + if not content: + raise HTTPException(status_code=400, detail="Invalid request json") + + deanonymize_entities = AppEntitiesConvertor.deanonymize_entities_from_json( + content + ) + deanonymize_config = AppEntitiesConvertor.operators_config_from_json( + content.get("deanonymizers") + ) + deanonymized_response = self.deanonymize.deanonymize( + text=content.get("text", ""), + entities=deanonymize_entities, + operators=deanonymize_config, + ) + return Response( + content=deanonymized_response.to_json(), + media_type="application/json", + ) + + @self.app.get("/anonymizers") + def anonymizers() -> list[str]: + """Return a list of supported anonymizers.""" + return self.anonymizer.get_anonymizers() + + @self.app.get("/deanonymizers") + def deanonymizers() -> list[str]: + """Return a list of supported deanonymizers.""" + return self.deanonymize.get_deanonymizers() + + def _add_error_handlers(self) -> None: + @self.app.exception_handler(InvalidParamError) + def invalid_param(_: Request, err: InvalidParamError) -> JSONResponse: + self.logger.warning( + "Request failed with parameter validation error: %s", err.err_msg + ) + return JSONResponse({"error": err.err_msg}, status_code=422) + + @self.app.exception_handler(HTTPException) + def http_exception(_: Request, err: HTTPException) -> JSONResponse: + return JSONResponse({"error": err.detail}, status_code=err.status_code) + + @self.app.exception_handler(Exception) + def server_error(_: Request, err: Exception) -> JSONResponse: + self.logger.error("A fatal error occurred during execution: %s", err) + return JSONResponse({"error": "Internal server error"}, status_code=500) + + +def create_app() -> FastAPI: + """Create the FastAPI application.""" + server = Server() + return server.app + + +app = create_app() + + +if __name__ == "__main__": + import uvicorn + + port = int(os.environ.get("PORT", DEFAULT_PORT)) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/presidio-anonymizer/pyproject.toml b/presidio-anonymizer/pyproject.toml index b53f482cb1..296ce4016d 100644 --- a/presidio-anonymizer/pyproject.toml +++ b/presidio-anonymizer/pyproject.toml @@ -31,6 +31,10 @@ server = [ "gunicorn (>=20.0.0,<26.0.0); platform_system != 'Windows'", "waitress (>=2.0.0,<4.0.0); platform_system == 'Windows'" ] +fastapi = [ + "fastapi (>=0.115.0,<1.0.0)", + "uvicorn (>=0.32.0,<1.0.0)" +] ahds = [ "azure-identity (>=1.25.3,<2.0.0)", "azure-health-deidentification (>=1.1.0b1,<2.0.0)" diff --git a/presidio-anonymizer/tests/test_fastapi_app.py b/presidio-anonymizer/tests/test_fastapi_app.py new file mode 100644 index 0000000000..ef17479dc4 --- /dev/null +++ b/presidio-anonymizer/tests/test_fastapi_app.py @@ -0,0 +1,74 @@ +"""Tests for the FastAPI anonymizer server.""" + +from importlib import util +from pathlib import Path + +import pytest + +pytest.importorskip("httpx") +fastapi_testclient = pytest.importorskip("fastapi.testclient") +TestClient = fastapi_testclient.TestClient + + +def _load_fastapi_app(): + module_path = Path(__file__).parents[1] / "fastapi_app.py" + spec = util.spec_from_file_location("presidio_anonymizer_fastapi_app", module_path) + module = util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.create_app() + + +def test_health_endpoint_returns_service_status(): + """Health endpoint mirrors the existing service status response.""" + client = TestClient(_load_fastapi_app()) + + response = client.get("/health") + + assert response.status_code == 200 + assert response.text == "Presidio Anonymizer service is up" + + +def test_anonymize_endpoint_returns_engine_response(): + """Anonymize endpoint returns the anonymizer engine JSON response.""" + client = TestClient(_load_fastapi_app()) + + response = client.post( + "/anonymize", + json={ + "text": "My name is Jane", + "analyzer_results": [ + { + "start": 11, + "end": 15, + "score": 0.8, + "entity_type": "PERSON", + } + ], + "anonymizers": { + "DEFAULT": {"type": "replace", "new_value": ""} + }, + }, + ) + + assert response.status_code == 200 + assert response.json()["text"] == "My name is " + + +def test_empty_json_body_returns_flask_compatible_error_shape(): + """Empty JSON requests keep the existing error response shape.""" + client = TestClient(_load_fastapi_app()) + + response = client.post("/anonymize", json={}) + + assert response.status_code == 400 + assert response.json() == {"error": "Invalid request json"} + + +def test_anonymizers_endpoint_returns_supported_operators(): + """Anonymizers endpoint exposes built-in anonymizer operators.""" + client = TestClient(_load_fastapi_app()) + + response = client.get("/anonymizers") + + assert response.status_code == 200 + assert "replace" in response.json() From 52e7f572c39454c092f8aef9fddb1e565f243413 Mon Sep 17 00:00:00 2001 From: Nachiket Date: Tue, 2 Jun 2026 20:01:48 -0700 Subject: [PATCH 2/6] test: cover FastAPI anonymizer review feedback --- presidio-anonymizer/README.md | 6 +-- presidio-anonymizer/fastapi_app.py | 21 +++++--- presidio-anonymizer/pyproject.toml | 2 + presidio-anonymizer/tests/test_fastapi_app.py | 48 ++++++++++++++++++- 4 files changed, 67 insertions(+), 10 deletions(-) diff --git a/presidio-anonymizer/README.md b/presidio-anonymizer/README.md index afa39cf979..1c8f3dcb9f 100644 --- a/presidio-anonymizer/README.md +++ b/presidio-anonymizer/README.md @@ -205,9 +205,9 @@ Anonymizer REST API reference details #### Optional FastAPI server -The anonymizer package also includes a FastAPI server with the same REST endpoints -as the default Flask server. Install the optional dependencies and run it with -Uvicorn: +The anonymizer source tree also includes a FastAPI server with the same REST +endpoints as the default Flask server. From the `presidio-anonymizer` source +directory, install the optional dependencies and run it with Uvicorn: ```sh pip install "presidio-anonymizer[fastapi]" diff --git a/presidio-anonymizer/fastapi_app.py b/presidio-anonymizer/fastapi_app.py index 7f203d9890..a6add7befd 100644 --- a/presidio-anonymizer/fastapi_app.py +++ b/presidio-anonymizer/fastapi_app.py @@ -1,5 +1,6 @@ """FastAPI REST API server for anonymizer.""" +import json import logging import os from logging.config import fileConfig @@ -50,9 +51,8 @@ def health() -> str: return "Presidio Anonymizer service is up" @self.app.post("/anonymize") - def anonymize(content: dict[str, Any]) -> Response: - if not content: - raise HTTPException(status_code=400, detail="Invalid request json") + async def anonymize(request: Request) -> Response: + content = await self._json_request_body(request) anonymizers_config = AppEntitiesConvertor.operators_config_from_json( content.get("anonymizers") @@ -75,9 +75,8 @@ def anonymize(content: dict[str, Any]) -> Response: ) @self.app.post("/deanonymize") - def deanonymize(content: dict[str, Any]) -> Response: - if not content: - raise HTTPException(status_code=400, detail="Invalid request json") + async def deanonymize(request: Request) -> Response: + content = await self._json_request_body(request) deanonymize_entities = AppEntitiesConvertor.deanonymize_entities_from_json( content @@ -105,6 +104,16 @@ def deanonymizers() -> list[str]: """Return a list of supported deanonymizers.""" return self.deanonymize.get_deanonymizers() + async def _json_request_body(self, request: Request) -> dict[str, Any]: + try: + content = await request.json() + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid request json") + + if not content or not isinstance(content, dict): + raise HTTPException(status_code=400, detail="Invalid request json") + return content + def _add_error_handlers(self) -> None: @self.app.exception_handler(InvalidParamError) def invalid_param(_: Request, err: InvalidParamError) -> JSONResponse: diff --git a/presidio-anonymizer/pyproject.toml b/presidio-anonymizer/pyproject.toml index 296ce4016d..11f5c1a069 100644 --- a/presidio-anonymizer/pyproject.toml +++ b/presidio-anonymizer/pyproject.toml @@ -49,6 +49,8 @@ pytest-mock = "*" python-dotenv = "*" pre_commit = "*" diff-cover = "*" +fastapi = "*" +httpx = "*" [tool.coverage.run] relative_files = true diff --git a/presidio-anonymizer/tests/test_fastapi_app.py b/presidio-anonymizer/tests/test_fastapi_app.py index ef17479dc4..d5e4e2b4d7 100644 --- a/presidio-anonymizer/tests/test_fastapi_app.py +++ b/presidio-anonymizer/tests/test_fastapi_app.py @@ -15,7 +15,7 @@ def _load_fastapi_app(): spec = util.spec_from_file_location("presidio_anonymizer_fastapi_app", module_path) module = util.module_from_spec(spec) spec.loader.exec_module(module) - return module.create_app() + return module.app def test_health_endpoint_returns_service_status(): @@ -64,6 +64,17 @@ def test_empty_json_body_returns_flask_compatible_error_shape(): assert response.json() == {"error": "Invalid request json"} +@pytest.mark.parametrize("request_kwargs", [{}, {"content": "not json"}]) +def test_invalid_json_body_returns_flask_compatible_error_shape(request_kwargs): + """Missing and invalid JSON requests keep the existing error response shape.""" + client = TestClient(_load_fastapi_app()) + + response = client.post("/anonymize", **request_kwargs) + + assert response.status_code == 400 + assert response.json() == {"error": "Invalid request json"} + + def test_anonymizers_endpoint_returns_supported_operators(): """Anonymizers endpoint exposes built-in anonymizer operators.""" client = TestClient(_load_fastapi_app()) @@ -72,3 +83,38 @@ def test_anonymizers_endpoint_returns_supported_operators(): assert response.status_code == 200 assert "replace" in response.json() + + +def test_deanonymize_endpoint_returns_engine_response(): + """Deanonymize endpoint returns the deanonymizer engine JSON response.""" + client = TestClient(_load_fastapi_app()) + + response = client.post( + "/deanonymize", + json={ + "text": "My name is Jane", + "anonymizer_results": [ + { + "start": 11, + "end": 15, + "entity_type": "PERSON", + "text": "Jane", + "operator": "keep", + } + ], + "deanonymizers": {"DEFAULT": {"type": "deanonymize_keep"}}, + }, + ) + + assert response.status_code == 200 + assert response.json()["text"] == "My name is Jane" + + +def test_deanonymizers_endpoint_returns_supported_operators(): + """Deanonymizers endpoint exposes built-in deanonymizer operators.""" + client = TestClient(_load_fastapi_app()) + + response = client.get("/deanonymizers") + + assert response.status_code == 200 + assert "deanonymize_keep" in response.json() From 61caa80b1ad4cfaee09a95abde42041b68be0f25 Mon Sep 17 00:00:00 2001 From: Nachiket Date: Wed, 17 Jun 2026 20:44:51 -0700 Subject: [PATCH 3/6] fix: address FastAPI review follow-ups --- presidio-anonymizer/README.md | 2 +- presidio-anonymizer/fastapi_app.py | 9 ++++++--- presidio-anonymizer/pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/presidio-anonymizer/README.md b/presidio-anonymizer/README.md index 1c8f3dcb9f..dfb4ca0d96 100644 --- a/presidio-anonymizer/README.md +++ b/presidio-anonymizer/README.md @@ -210,6 +210,6 @@ endpoints as the default Flask server. From the `presidio-anonymizer` source directory, install the optional dependencies and run it with Uvicorn: ```sh -pip install "presidio-anonymizer[fastapi]" +pip install -e ".[fastapi]" uvicorn fastapi_app:app --host 0.0.0.0 --port 3000 ``` diff --git a/presidio-anonymizer/fastapi_app.py b/presidio-anonymizer/fastapi_app.py index a6add7befd..ea951e0b1b 100644 --- a/presidio-anonymizer/fastapi_app.py +++ b/presidio-anonymizer/fastapi_app.py @@ -12,6 +12,7 @@ from presidio_anonymizer import AnonymizerEngine, DeanonymizeEngine from presidio_anonymizer.entities import InvalidParamError from presidio_anonymizer.services.app_entities_convertor import AppEntitiesConvertor +from starlette.concurrency import run_in_threadpool DEFAULT_PORT = "3000" @@ -65,7 +66,8 @@ async def anonymize(request: Request) -> Response: analyzer_results = AppEntitiesConvertor.analyzer_results_from_json( content.get("analyzer_results") ) - anonymizer_result = self.anonymizer.anonymize( + anonymizer_result = await run_in_threadpool( + self.anonymizer.anonymize, text=content.get("text", ""), analyzer_results=analyzer_results, operators=anonymizers_config, @@ -84,7 +86,8 @@ async def deanonymize(request: Request) -> Response: deanonymize_config = AppEntitiesConvertor.operators_config_from_json( content.get("deanonymizers") ) - deanonymized_response = self.deanonymize.deanonymize( + deanonymized_response = await run_in_threadpool( + self.deanonymize.deanonymize, text=content.get("text", ""), entities=deanonymize_entities, operators=deanonymize_config, @@ -128,7 +131,7 @@ def http_exception(_: Request, err: HTTPException) -> JSONResponse: @self.app.exception_handler(Exception) def server_error(_: Request, err: Exception) -> JSONResponse: - self.logger.error("A fatal error occurred during execution: %s", err) + self.logger.exception("A fatal error occurred during execution") return JSONResponse({"error": "Internal server error"}, status_code=500) diff --git a/presidio-anonymizer/pyproject.toml b/presidio-anonymizer/pyproject.toml index 11f5c1a069..3aa399a096 100644 --- a/presidio-anonymizer/pyproject.toml +++ b/presidio-anonymizer/pyproject.toml @@ -49,7 +49,7 @@ pytest-mock = "*" python-dotenv = "*" pre_commit = "*" diff-cover = "*" -fastapi = "*" +fastapi = ">=0.115.0,<1.0.0" httpx = "*" [tool.coverage.run] From 118b1e6bb840dc9f39711c779655314892477dee Mon Sep 17 00:00:00 2001 From: Nachiket Date: Thu, 18 Jun 2026 02:46:33 -0700 Subject: [PATCH 4/6] fix: defer FastAPI app initialization --- presidio-anonymizer/README.md | 2 +- presidio-anonymizer/fastapi_app.py | 5 +---- presidio-anonymizer/tests/test_fastapi_app.py | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/presidio-anonymizer/README.md b/presidio-anonymizer/README.md index dfb4ca0d96..ad70b5a5ee 100644 --- a/presidio-anonymizer/README.md +++ b/presidio-anonymizer/README.md @@ -211,5 +211,5 @@ directory, install the optional dependencies and run it with Uvicorn: ```sh pip install -e ".[fastapi]" -uvicorn fastapi_app:app --host 0.0.0.0 --port 3000 +uvicorn fastapi_app:create_app --factory --host 0.0.0.0 --port 3000 ``` diff --git a/presidio-anonymizer/fastapi_app.py b/presidio-anonymizer/fastapi_app.py index ea951e0b1b..fa54af6e32 100644 --- a/presidio-anonymizer/fastapi_app.py +++ b/presidio-anonymizer/fastapi_app.py @@ -141,11 +141,8 @@ def create_app() -> FastAPI: return server.app -app = create_app() - - if __name__ == "__main__": import uvicorn port = int(os.environ.get("PORT", DEFAULT_PORT)) - uvicorn.run(app, host="0.0.0.0", port=port) + uvicorn.run(create_app(), host="0.0.0.0", port=port) diff --git a/presidio-anonymizer/tests/test_fastapi_app.py b/presidio-anonymizer/tests/test_fastapi_app.py index d5e4e2b4d7..cc9159a338 100644 --- a/presidio-anonymizer/tests/test_fastapi_app.py +++ b/presidio-anonymizer/tests/test_fastapi_app.py @@ -15,7 +15,7 @@ def _load_fastapi_app(): spec = util.spec_from_file_location("presidio_anonymizer_fastapi_app", module_path) module = util.module_from_spec(spec) spec.loader.exec_module(module) - return module.app + return module.create_app() def test_health_endpoint_returns_service_status(): From 7baa962ec30f2e1e59d9fc6cebb89d73d697313f Mon Sep 17 00:00:00 2001 From: Nachiket Date: Sun, 9 Aug 2026 02:40:17 -0700 Subject: [PATCH 5/6] Allow httpcore composite BSD license in CI --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85024ed73b..2a4f186fb8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.5.0 with: fail-on-severity: low - allow-licenses: MIT, Apache-2.0, BSD-3-Clause, 0BSD, 0BSD AND Apache-2.0 AND BSD-3-Clause AND MIT + allow-licenses: MIT, Apache-2.0, BSD-3-Clause, BSD-2-Clause AND BSD-3-Clause, 0BSD, 0BSD AND Apache-2.0 AND BSD-3-Clause AND MIT comment-summary-in-pr: on-failure test: From 7bc415fd60ff54d4b1780ffed141765db1bd4758 Mon Sep 17 00:00:00 2001 From: Nachiket Date: Sun, 9 Aug 2026 02:43:36 -0700 Subject: [PATCH 6/6] Avoid httpx dependency in FastAPI tests --- .github/workflows/ci.yml | 2 +- presidio-anonymizer/pyproject.toml | 1 - presidio-anonymizer/tests/test_fastapi_app.py | 96 +++++++++++++++---- presidio-anonymizer/uv.lock | 30 ------ 4 files changed, 79 insertions(+), 50 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a4f186fb8..85024ed73b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.5.0 with: fail-on-severity: low - allow-licenses: MIT, Apache-2.0, BSD-3-Clause, BSD-2-Clause AND BSD-3-Clause, 0BSD, 0BSD AND Apache-2.0 AND BSD-3-Clause AND MIT + allow-licenses: MIT, Apache-2.0, BSD-3-Clause, 0BSD, 0BSD AND Apache-2.0 AND BSD-3-Clause AND MIT comment-summary-in-pr: on-failure test: diff --git a/presidio-anonymizer/pyproject.toml b/presidio-anonymizer/pyproject.toml index 2504134e6a..02dc8ce632 100644 --- a/presidio-anonymizer/pyproject.toml +++ b/presidio-anonymizer/pyproject.toml @@ -52,7 +52,6 @@ dev = [ "pre-commit", "diff-cover", "fastapi>=0.115.0,<1.0.0", - "httpx", ] [tool.coverage.run] diff --git a/presidio-anonymizer/tests/test_fastapi_app.py b/presidio-anonymizer/tests/test_fastapi_app.py index cc9159a338..924ebe5dd2 100644 --- a/presidio-anonymizer/tests/test_fastapi_app.py +++ b/presidio-anonymizer/tests/test_fastapi_app.py @@ -1,14 +1,13 @@ """Tests for the FastAPI anonymizer server.""" +import asyncio +import json from importlib import util from pathlib import Path +from typing import Any import pytest -pytest.importorskip("httpx") -fastapi_testclient = pytest.importorskip("fastapi.testclient") -TestClient = fastapi_testclient.TestClient - def _load_fastapi_app(): module_path = Path(__file__).parents[1] / "fastapi_app.py" @@ -18,11 +17,68 @@ def _load_fastapi_app(): return module.create_app() +class _Response: + def __init__(self, status_code: int, body: bytes) -> None: + self.status_code = status_code + self.text = body.decode() + + def json(self) -> Any: + return json.loads(self.text) + + +def _request(app, method: str, path: str, **request_kwargs) -> _Response: + body = b"" + headers = [] + if "json" in request_kwargs: + body = json.dumps(request_kwargs["json"]).encode() + headers.append((b"content-type", b"application/json")) + elif "content" in request_kwargs: + body = request_kwargs["content"].encode() + + messages = [] + request_sent = False + + async def receive(): + nonlocal request_sent + if request_sent: + return {"type": "http.disconnect"} + request_sent = True + return {"type": "http.request", "body": body, "more_body": False} + + async def send(message): + messages.append(message) + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": b"", + "headers": headers, + "client": ("testclient", 50000), + "server": ("testserver", 80), + } + + asyncio.run(app(scope, receive, send)) + start = next( + message for message in messages if message["type"] == "http.response.start" + ) + response_body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return _Response(start["status"], response_body) + + def test_health_endpoint_returns_service_status(): """Health endpoint mirrors the existing service status response.""" - client = TestClient(_load_fastapi_app()) + app = _load_fastapi_app() - response = client.get("/health") + response = _request(app, "GET", "/health") assert response.status_code == 200 assert response.text == "Presidio Anonymizer service is up" @@ -30,9 +86,11 @@ def test_health_endpoint_returns_service_status(): def test_anonymize_endpoint_returns_engine_response(): """Anonymize endpoint returns the anonymizer engine JSON response.""" - client = TestClient(_load_fastapi_app()) + app = _load_fastapi_app() - response = client.post( + response = _request( + app, + "POST", "/anonymize", json={ "text": "My name is Jane", @@ -56,9 +114,9 @@ def test_anonymize_endpoint_returns_engine_response(): def test_empty_json_body_returns_flask_compatible_error_shape(): """Empty JSON requests keep the existing error response shape.""" - client = TestClient(_load_fastapi_app()) + app = _load_fastapi_app() - response = client.post("/anonymize", json={}) + response = _request(app, "POST", "/anonymize", json={}) assert response.status_code == 400 assert response.json() == {"error": "Invalid request json"} @@ -67,9 +125,9 @@ def test_empty_json_body_returns_flask_compatible_error_shape(): @pytest.mark.parametrize("request_kwargs", [{}, {"content": "not json"}]) def test_invalid_json_body_returns_flask_compatible_error_shape(request_kwargs): """Missing and invalid JSON requests keep the existing error response shape.""" - client = TestClient(_load_fastapi_app()) + app = _load_fastapi_app() - response = client.post("/anonymize", **request_kwargs) + response = _request(app, "POST", "/anonymize", **request_kwargs) assert response.status_code == 400 assert response.json() == {"error": "Invalid request json"} @@ -77,9 +135,9 @@ def test_invalid_json_body_returns_flask_compatible_error_shape(request_kwargs): def test_anonymizers_endpoint_returns_supported_operators(): """Anonymizers endpoint exposes built-in anonymizer operators.""" - client = TestClient(_load_fastapi_app()) + app = _load_fastapi_app() - response = client.get("/anonymizers") + response = _request(app, "GET", "/anonymizers") assert response.status_code == 200 assert "replace" in response.json() @@ -87,9 +145,11 @@ def test_anonymizers_endpoint_returns_supported_operators(): def test_deanonymize_endpoint_returns_engine_response(): """Deanonymize endpoint returns the deanonymizer engine JSON response.""" - client = TestClient(_load_fastapi_app()) + app = _load_fastapi_app() - response = client.post( + response = _request( + app, + "POST", "/deanonymize", json={ "text": "My name is Jane", @@ -112,9 +172,9 @@ def test_deanonymize_endpoint_returns_engine_response(): def test_deanonymizers_endpoint_returns_supported_operators(): """Deanonymizers endpoint exposes built-in deanonymizer operators.""" - client = TestClient(_load_fastapi_app()) + app = _load_fastapi_app() - response = client.get("/deanonymizers") + response = _request(app, "GET", "/deanonymizers") assert response.status_code == 200 assert "deanonymize_keep" in response.json() diff --git a/presidio-anonymizer/uv.lock b/presidio-anonymizer/uv.lock index 3b842f12cf..1c63e28b17 100644 --- a/presidio-anonymizer/uv.lock +++ b/presidio-anonymizer/uv.lock @@ -603,34 +603,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - [[package]] name = "identify" version = "2.6.19" @@ -887,7 +859,6 @@ server = [ dev = [ { name = "diff-cover" }, { name = "fastapi" }, - { name = "httpx" }, { name = "pip" }, { name = "pre-commit" }, { name = "pytest" }, @@ -914,7 +885,6 @@ provides-extras = ["server", "fastapi", "ahds"] dev = [ { name = "diff-cover" }, { name = "fastapi", specifier = ">=0.115.0,<1.0.0" }, - { name = "httpx" }, { name = "pip" }, { name = "pre-commit" }, { name = "pytest" },