feat: add FastAPI anonymizer server#2039
Open
ynachiket wants to merge 5 commits into
Open
Conversation
Contributor
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. |
Comment on lines
+52
to
+56
| @self.app.post("/anonymize") | ||
| def anonymize(content: dict[str, Any]) -> Response: | ||
| if not content: | ||
| raise HTTPException(status_code=400, detail="Invalid request json") | ||
|
|
Comment on lines
+6
to
+10
| 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() |
Comment on lines
+208
to
+215
| 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 | ||
| ``` |
Comment on lines
+31
to
+74
| 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() |
Contributor
Author
|
Addressed the Copilot review feedback in
Validation:
GitHub checks had not been reported yet immediately after push. |
SharonHart
approved these changes
Jun 7, 2026
Comment on lines
+53
to
+76
| @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" | ||
| ) | ||
|
|
Comment on lines
+77
to
+96
| @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", | ||
| ) | ||
|
|
Comment on lines
+129
to
+132
| @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) |
Comment on lines
+53
to
+75
| @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" | ||
| ) |
Comment on lines
+77
to
+96
| @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", | ||
| ) | ||
|
|
Comment on lines
+52
to
+53
| fastapi = "*" | ||
| httpx = "*" |
| directory, install the optional dependencies and run it with Uvicorn: | ||
|
|
||
| ```sh | ||
| pip install "presidio-anonymizer[fastapi]" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.