feat: add FastAPI anonymizer server - #2039
Conversation
There was a problem hiding this comment.
Pull request overview
Adds an optional FastAPI-based REST server for presidio-anonymizer, intended to mirror the existing Flask anonymizer service endpoints as an initial slice toward #1769.
Changes:
- Introduces
fastapi_app.pyimplementing/health,/anonymize,/deanonymize,/anonymizers, and/deanonymizersin FastAPI. - Adds a new
fastapioptional dependency extra (FastAPI + Uvicorn). - Adds a minimal README run snippet and a new test module for the FastAPI app.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| presidio-anonymizer/fastapi_app.py | New FastAPI server implementation mirroring the existing Flask routes and error shapes. |
| presidio-anonymizer/pyproject.toml | Adds fastapi optional-dependency extra for running the FastAPI server. |
| presidio-anonymizer/README.md | Documents how to run the optional FastAPI server with Uvicorn. |
| presidio-anonymizer/tests/test_fastapi_app.py | Adds initial tests for the FastAPI server endpoints and error response shape. |
| @self.app.post("/anonymize") | ||
| def anonymize(content: dict[str, Any]) -> Response: | ||
| if not content: | ||
| raise HTTPException(status_code=400, detail="Invalid request json") | ||
|
|
| import pytest | ||
|
|
||
| pytest.importorskip("httpx") | ||
| fastapi_testclient = pytest.importorskip("fastapi.testclient") | ||
| TestClient = fastapi_testclient.TestClient |
| 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() |
| 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 | ||
| ``` |
| 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": "<ANONYMIZED>"} | ||
| }, | ||
| }, | ||
| ) | ||
|
|
||
| assert response.status_code == 200 | ||
| assert response.json()["text"] == "My name is <ANONYMIZED>" | ||
|
|
||
|
|
||
| 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() |
|
Addressed the Copilot review feedback in
Validation:
GitHub checks had not been reported yet immediately after push. |
| @self.app.post("/anonymize") | ||
| async def anonymize(request: Request) -> Response: | ||
| content = await self._json_request_body(request) | ||
|
|
||
| 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") | ||
| async def deanonymize(request: Request) -> Response: | ||
| content = await self._json_request_body(request) | ||
|
|
||
| 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.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) |
| @self.app.post("/anonymize") | ||
| async def anonymize(request: Request) -> Response: | ||
| content = await self._json_request_body(request) | ||
|
|
||
| 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") | ||
| async def deanonymize(request: Request) -> Response: | ||
| content = await self._json_request_body(request) | ||
|
|
||
| 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", | ||
| ) | ||
|
|
| fastapi = "*" | ||
| httpx = "*" |
| directory, install the optional dependencies and run it with Uvicorn: | ||
|
|
||
| ```sh | ||
| pip install "presidio-anonymizer[fastapi]" |
|
Addressed the latest FastAPI review follow-ups in
Validation:
|
| return server.app | ||
|
|
||
|
|
||
| app = create_app() |
|
Addressed the latest Copilot FastAPI factory feedback in |
Change
Adds an optional FastAPI server for
presidio-anonymizeras a first narrow slice of #1769. The newfastapi_app.pymirrors the existing Flask anonymizer REST endpoints:GET /healthPOST /anonymizePOST /deanonymizeGET /anonymizersGET /deanonymizersThe existing Flask app and Docker entrypoint are unchanged. FastAPI and Uvicorn are exposed through a new optional
fastapiextra, and the README includes a minimal run command.Validation
/tmp/presidio-fastapi-1769-py312/bin/python -m py_compile presidio-anonymizer/fastapi_app.py presidio-anonymizer/tests/test_fastapi_app.pycd presidio-anonymizer && /tmp/presidio-fastapi-1769-py312/bin/python -m pytest tests/test_fastapi_app.py -q->4 passedcd presidio-anonymizer && /tmp/presidio-fastapi-1769-py312/bin/ruff check fastapi_app.py tests/test_fastapi_app.pygit diff --checkCloses #1769? No, this is intentionally a first anonymizer-only slice toward the broader multi-service request.