diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 82e8674..1389f26 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: 3.7 + python-version: "3.10" - name: Run the SDK testserver run: | diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 82771fd..77bbe0e 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: "3.10" - name: Install Dependencies working-directory: ./ diff --git a/README.md b/README.md index 9a87cc9..9c1739d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ pip install friendly-captcha-client Below are some basic examples of how to use the client. -For a more detailed example, take a look at the [example](./example) directory. +For a more detailed examples, take a look at the [example](./example) directory. ### Initialization @@ -71,6 +71,17 @@ print(result.should_accept) # False print(result.was_able_to_verify) # False ``` +### Risk Intelligence Data Retrieval + +Use `retrieve_risk_intelligence` to retrieve risk intelligence data from a token. + +```python +result = client.retrieve_risk_intelligence("RISK_INTELLIGENCE_TOKEN_HERE") +print(result.was_able_to_retrieve) # True/False +print(result.is_client_error) # True when the API responded with a client-side error +print(result.data) # The risk intelligence data +``` + ### Configuration The client offers several configuration options: @@ -78,7 +89,8 @@ The client offers several configuration options: - **api_key**: Your Friendly Captcha API key. - **sitekey**: Your Friendly Captcha sitekey. - **strict**: (Optional) In case the client was not able to verify the captcha response at all (for example if there is a network failure or a mistake in configuration), by default the `verify_captcha_response` returns `True` regardless. By passing `strict=True`, it will return `False` instead: every response needs to be strictly verified. -- **siteverify_endpoint**: (Optional) The endpoint URL for the site verification API. Shorthands `eu` or `global` are also accepted. Default is `global`. +- **api_endpoint**: (Optional) Base API endpoint (for example `https://eu.frcapi.com`). Shorthands `eu` or `global` are also accepted. Default is `global`. +- **siteverify_endpoint**: (Optional,Deprecated) Kept for backwards compatibility, use `api_endpoint` instead. Accepts a full siteverify URL or shorthands `eu`/`global`; path is stripped and converted to `api_endpoint`. - **verbose**: (Optional) Default is False. Turn on basic logging. - Error Handling: The client has built-in error handling mechanisms. In case of unexpected responses or errors from the Friendly Captcha API, the client will log the error and provide a default response. diff --git a/example/README.md b/example/captcha/README.md similarity index 59% rename from example/README.md rename to example/captcha/README.md index 0057594..ea1a809 100644 --- a/example/README.md +++ b/example/captcha/README.md @@ -4,7 +4,7 @@ This application integrates Friendly Captcha for form submissions using FastAPI. ### Requirements -- Python 3.9+ +- Python 3.10+ - Your Friendly Captcha API key and sitekey. ### Start the application @@ -26,10 +26,11 @@ pip install -r requirements.txt - Setup env variables and start the application -> NOTE: `FRC_SITEVERIFY_ENDPOINT` and `FRC_WIDGET_ENDPOINT` are optional. If not set, the default values will be used. You can also use `global` or `eu` as shorthands for both. +> NOTE: `FRC_API_ENDPOINT` and `FRC_WIDGET_ENDPOINT` are optional. If not set, the default values will be used. You can also use `global` or `eu` as shorthands for both. +> For the frontend `data-api-endpoint`, use the base endpoint (for example `http://localhost:8182`), not `/api/v2/captcha`. ```bash -FRC_APIKEY= FRC_SITEKEY= FRC_SITEVERIFY_ENDPOINT= FRC_WIDGET_ENDPOINT= uvicorn main:app --reload --port 8000 +FRC_APIKEY= FRC_SITEKEY= FRC_API_ENDPOINT= FRC_WIDGET_ENDPOINT= uvicorn main:app --reload --port 8000 ``` # Usage diff --git a/example/main.py b/example/captcha/main.py similarity index 94% rename from example/main.py rename to example/captcha/main.py index 5d6968a..ba1ab58 100644 --- a/example/main.py +++ b/example/captcha/main.py @@ -12,7 +12,8 @@ FRC_APIKEY = os.getenv("FRC_APIKEY") # Optionally we can pass in custom endpoints to be used, such as "eu". -FRC_SITEVERIFY_ENDPOINT = os.getenv("FRC_SITEVERIFY_ENDPOINT") +FRC_API_ENDPOINT = os.getenv("FRC_API_ENDPOINT") +# Optional: frontend widget endpoint used for data-api-endpoint. FRC_WIDGET_ENDPOINT = os.getenv("FRC_WIDGET_ENDPOINT") if not FRC_SITEKEY or not FRC_APIKEY: @@ -24,7 +25,7 @@ frc_client = FriendlyCaptchaClient( api_key=FRC_APIKEY, sitekey=FRC_SITEKEY, - siteverify_endpoint=FRC_SITEVERIFY_ENDPOINT, # Optional, defaults to "global" + api_endpoint=FRC_API_ENDPOINT, # Optional, defaults to "global" strict=False, ) diff --git a/example/captcha/requirements.txt b/example/captcha/requirements.txt new file mode 100644 index 0000000..138f8ae --- /dev/null +++ b/example/captcha/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.101.1 +uvicorn==0.26.0 +jinja2==3.1.3 +python-multipart==0.0.6 +# TODO uncomment and bump version when the new SDK version is released +# friendly-captcha-client==0.0.2 + +# TODO remove this line when the new SDK version is released +-e ../.. diff --git a/example/templates/demo.html b/example/captcha/templates/demo.html similarity index 94% rename from example/templates/demo.html rename to example/captcha/templates/demo.html index 8559e0a..13dfe34 100644 --- a/example/templates/demo.html +++ b/example/captcha/templates/demo.html @@ -47,9 +47,8 @@ } - - + + @@ -81,4 +80,4 @@

Friendly Captcha Python SDK form

- \ No newline at end of file + diff --git a/example/requirements.txt b/example/requirements.txt deleted file mode 100644 index c080d91..0000000 --- a/example/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -fastapi==0.101.1 -uvicorn==0.26.0 -jinja2==3.1.3 -python-multipart==0.0.6 -friendly-captcha-client==0.0.2 diff --git a/example/risk-intelligence/README.md b/example/risk-intelligence/README.md new file mode 100644 index 0000000..24313c4 --- /dev/null +++ b/example/risk-intelligence/README.md @@ -0,0 +1,37 @@ +# Friendly Captcha Python Risk Intelligence Example + +This example demonstrates server-side risk intelligence retrieval with `retrieve_risk_intelligence`. + +### Requirements + +- Python 3.10+ +- Your Friendly Captcha API key and sitekey. + +### Start the application + +- Set up a virtual environment (recommended): + +```bash +python -m venv venv +source venv/bin/activate # On Windows, use `venv\Scripts\activate` +pip install -r requirements.txt +``` + +- Set environment variables and start the application + +> NOTE: `FRC_API_ENDPOINT` and `FRC_AGENT_ENDPOINT` are optional. If not set, default values are used. You can also use `global` or `eu` as shorthands. + +```bash +FRC_APIKEY= \ +FRC_SITEKEY= \ +FRC_API_ENDPOINT= \ +FRC_AGENT_ENDPOINT= \ +uvicorn main:app --reload --port 8000 +``` + +## Usage + +Navigate to http://localhost:8000/ in your browser. +The token generation starts automatically. Submit the form to retrieve the risk intelligence data server-side. +Tokens are cached in the browser for the duration of their validity period so refreshing the page does not regenerate the token. +You can regenerate the token by clicking the "Regenerate Token" button. diff --git a/example/risk-intelligence/main.py b/example/risk-intelligence/main.py new file mode 100644 index 0000000..120f35b --- /dev/null +++ b/example/risk-intelligence/main.py @@ -0,0 +1,103 @@ +import json +import os + +from fastapi import FastAPI, Form, Request +from fastapi.templating import Jinja2Templates + +from friendly_captcha_client.client import ( + FriendlyCaptchaClient, + RiskIntelligenceRetrieveResult, +) + +app = FastAPI() +templates = Jinja2Templates(directory="./templates/") + +FRC_SITEKEY = os.getenv("FRC_SITEKEY") +FRC_APIKEY = os.getenv("FRC_APIKEY") + +# Optional: "global", "eu", or a full API base endpoint like "https://eu.frcapi.com". +FRC_API_ENDPOINT = os.getenv("FRC_API_ENDPOINT") +# Optional: SDK/agent endpoint used in the browser widget. +FRC_AGENT_ENDPOINT = os.getenv("FRC_AGENT_ENDPOINT") + +if not FRC_SITEKEY or not FRC_APIKEY: + print( + "Please set FRC_SITEKEY and FRC_APIKEY before running this example to your Friendly Captcha sitekey and API key respectively." + ) + exit(1) + +frc_client = FriendlyCaptchaClient( + api_key=FRC_APIKEY, + sitekey=FRC_SITEKEY, + api_endpoint=FRC_API_ENDPOINT, + strict=False, +) + + +def _render_template(request: Request, **values): + return templates.TemplateResponse("demo.html", {"request": request, **values}) + + +def _base_template_data(): + return { + "message": "", + "sitekey": FRC_SITEKEY, + "agent_endpoint": FRC_AGENT_ENDPOINT or "", + "risk_token": "", + "token_timestamp": "", + "token_expires_at": "", + "token_num_uses": "", + "risk_intelligence_raw": "", + } + + +@app.get("/") +def read_root(request: Request): + return _render_template(request, **_base_template_data()) + + +@app.post("/") +def post_form( + request: Request, + frc_risk_intelligence_token: str = Form("", alias="frc-risk-intelligence-token"), +): + data = _base_template_data() + + risk_token = (frc_risk_intelligence_token or "").strip() + if not risk_token: + data["message"] = "No risk intelligence token found." + return _render_template(request, **data) + + data["risk_token"] = risk_token + + result: RiskIntelligenceRetrieveResult = frc_client.retrieve_risk_intelligence( + risk_token + ) + if not result.was_able_to_retrieve: + data["message"] = "Risk intelligence retrieval failed: {}".format(result.error) + return _render_template(request, **data) + + if result.data is None: + data["message"] = ( + "Risk intelligence retrieve succeeded, but no data was returned." + ) + return _render_template(request, **data) + + if result.data.risk_intelligence is None: + data["message"] = ( + "Token was valid, but risk intelligence data was not returned." + ) + data["token_timestamp"] = result.data.details.timestamp + data["token_expires_at"] = result.data.details.expires_at + data["token_num_uses"] = result.data.details.num_uses + return _render_template(request, **data) + + data["message"] = "Retrieved risk intelligence data successfully." + data["token_timestamp"] = result.data.details.timestamp + data["token_expires_at"] = result.data.details.expires_at + data["token_num_uses"] = result.data.details.num_uses + data["risk_intelligence_raw"] = json.dumps( + result.data.risk_intelligence.model_dump(by_alias=True), + indent=2, + ) + return _render_template(request, **data) diff --git a/example/risk-intelligence/requirements.txt b/example/risk-intelligence/requirements.txt new file mode 100644 index 0000000..138f8ae --- /dev/null +++ b/example/risk-intelligence/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.101.1 +uvicorn==0.26.0 +jinja2==3.1.3 +python-multipart==0.0.6 +# TODO uncomment and bump version when the new SDK version is released +# friendly-captcha-client==0.0.2 + +# TODO remove this line when the new SDK version is released +-e ../.. diff --git a/example/risk-intelligence/templates/demo.html b/example/risk-intelligence/templates/demo.html new file mode 100644 index 0000000..96c0f6b --- /dev/null +++ b/example/risk-intelligence/templates/demo.html @@ -0,0 +1,212 @@ + + + + + + + + Friendly Captcha Python Risk Intelligence Example + + + + + + + +
+

Risk Intelligence

+ {% if message %} +

{{ message }}

+ {% endif %} + +
+

Form

+

Risk Intelligence token generation starts automatically. Submit to retrieve the data server-side.

+ +
+
+ + + +
+ +
waiting for token...
+
+ Show token +
No token generated yet.
+
+ +

Server Response

+ {% if risk_token %} +

Generated at: {{ token_timestamp }}

+

Expires at: {{ token_expires_at }}

+

Uses: {{ token_num_uses }}

+ {% endif %} + + {% if risk_intelligence_raw %} +

risk_intelligence JSON

+
{{ risk_intelligence_raw }}
+ {% endif %} +
+
+ + + + + \ No newline at end of file diff --git a/friendly_captcha_client/client.py b/friendly_captcha_client/client.py index d5d0d3a..b9ea156 100644 --- a/friendly_captcha_client/client.py +++ b/friendly_captcha_client/client.py @@ -1,90 +1,33 @@ -from typing import Optional, Union -from enum import Enum import logging +from typing import Any, Tuple, Type, TypeVar, Union +from urllib.parse import urlparse import requests -from pydantic import BaseModel, field_validator, model_validator, ValidationError - -GLOBAL_SITEVERIFY_ENDPOINT = "https://global.frcapi.com/api/v2/captcha/siteverify" -EU_SITEVERIFY_ENDPOINT = "https://eu.frcapi.com/api/v2/captcha/siteverify" - -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +from pydantic import BaseModel, ValidationError + +from friendly_captcha_client.schemas import ( + FriendlyCaptchaResponse, + FriendlyCaptchaResult, + RiskIntelligenceRetrieveResponse, + RiskIntelligenceRetrieveResult, + DefaultErrorCodes, + Error, + DECODE_RESPONSE_FAILED_INTERNAL_ERROR_CODE, + NON_STRICT_ERROR_CODES, ) -DECODE_RESPONSE_FAILED_INTERNAL_ERROR_CODE = "decode_response_failed" -NON_STRICT_ERROR_CODES = [ - "auth_required", - "auth_invalid", - "sitekey_invalid", - "response_missing", - "bad_request", - "client_error", -] - - -class DefaultErrorCodes(str, Enum): - AUTH_REQUIRED = "auth_required" # 401 - AUTH_INVALID = "auth_invalid" # 401 - SITEKEY_INVALID = "sitekey_invalid" # 400 - RESPONSE_MISSING = "response_missing" # 400 - BAD_REQUEST = "bad_request" # 400 - RESPONSE_INVALID = "response_invalid" # 200 - RESPONSE_TIMEOUT = "response_timeout" # 200 - RESPONSE_DUPLICATE = "response_duplicate" # 200 - CLIENT_ERROR = "request_failed_due_to_client_error" - - @staticmethod - def contains(value: str) -> bool: - return value in DefaultErrorCodes._value2member_map_ - - -class Error(BaseModel): - error_code: str - detail: str - - @field_validator("error_code") - def error_code(cls, v: str): - """Validate and convert the error code to its enum representation if it exists.""" - if DefaultErrorCodes.contains(v): - return DefaultErrorCodes(v) - return v or DECODE_RESPONSE_FAILED_INTERNAL_ERROR_CODE - - @field_validator("detail") - def detail(cls, v: str): - """Return the error detail or a default message if not provided.""" - return v or "Unknown error detail" - +GLOBAL_API_ENDPOINT = "https://global.frcapi.com" +EU_API_ENDPOINT = "https://eu.frcapi.com" -class Challenge(BaseModel): - timestamp: str - origin: str +CAPTCHA_SITEVERIFY_PATH = "/api/v2/captcha/siteverify" +RISK_INTELLIGENCE_RETRIEVE_PATH = "/api/v2/riskIntelligence/retrieve" -class Data(BaseModel): - challenge: Challenge - - -class FriendlyCaptchaResponse(BaseModel): - success: bool - data: Optional[Data] = None - error: Optional[Error] = None - - @model_validator(mode="after") - def check_data_or_error(cls, values): - if values.success and values.error: - raise ValueError("If success is True, error should not be set.") - if not values.success and values.data: - raise ValueError("If success is False, data should not be set.") - return values - +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) -class FriendlyCaptchaResult(BaseModel): - should_accept: bool - was_able_to_verify: bool - data: Optional[Data] = None - error: Optional[Error] = None - is_client_error: bool = False +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel) class FriendlyCaptchaClient: @@ -92,9 +35,11 @@ def __init__( self, api_key: str, sitekey: str, + # Deprecated: use api_endpoint instead. siteverify_endpoint: str = None, strict=False, verbose=False, + api_endpoint: str = None, ): self.api_key = api_key self.sitekey = sitekey @@ -102,90 +47,143 @@ def __init__( self.logger = logging.getLogger(__name__) self.verbose = verbose - if siteverify_endpoint is None or siteverify_endpoint == "global": - siteverify_endpoint = GLOBAL_SITEVERIFY_ENDPOINT - elif siteverify_endpoint == "eu": - siteverify_endpoint = EU_SITEVERIFY_ENDPOINT - self.siteverify_endpoint = siteverify_endpoint - - self._non_strict_error_code = [ - DefaultErrorCodes.AUTH_REQUIRED, - DefaultErrorCodes.AUTH_INVALID, - DefaultErrorCodes.SITEKEY_INVALID, - DefaultErrorCodes.RESPONSE_MISSING, - DefaultErrorCodes.BAD_REQUEST, - DefaultErrorCodes.CLIENT_ERROR, - ] - - self._strict_error_code = [ - DefaultErrorCodes.RESPONSE_INVALID, - DefaultErrorCodes.RESPONSE_TIMEOUT, - DefaultErrorCodes.RESPONSE_DUPLICATE, - ] + resolved_api_endpoint = self._resolve_api_endpoint(api_endpoint) + + if api_endpoint is None and siteverify_endpoint is not None: + resolved_api_endpoint = self._deprecated_endpoint_to_api_endpoint( + siteverify_endpoint, + "siteverify_endpoint", + ) + + self.api_endpoint = resolved_api_endpoint.rstrip("/") + + self._non_strict_error_code = set(NON_STRICT_ERROR_CODES) + + @staticmethod + def _resolve_api_endpoint(api_endpoint: str) -> str: + if api_endpoint is None or api_endpoint == "global": + return GLOBAL_API_ENDPOINT + if api_endpoint == "eu": + return EU_API_ENDPOINT + if api_endpoint == "": + raise ValueError("api_endpoint must not be empty") + return api_endpoint.rstrip("/") @staticmethod - def _create_friendly_response_with_error(raw_response, default_error_detail): + def _deprecated_endpoint_to_api_endpoint( + deprecated_endpoint: str, endpoint_param_name: str + ) -> str: + if deprecated_endpoint == "": + raise ValueError("{} must not be empty".format(endpoint_param_name)) + + if deprecated_endpoint in ("global", "eu"): + return FriendlyCaptchaClient._resolve_api_endpoint(deprecated_endpoint) + + parsed = urlparse(deprecated_endpoint) + if not parsed.scheme or not parsed.netloc: + raise ValueError( + "invalid {} URL: expected fully qualified URL".format( + endpoint_param_name + ) + ) + + return "{}://{}".format(parsed.scheme, parsed.netloc) + + def _api_url(self, path: str) -> str: + return "{}{}".format(self.api_endpoint, path) + + @property + def siteverify_endpoint(self) -> str: + return self._api_url(CAPTCHA_SITEVERIFY_PATH) + + @property + def risk_intelligence_retrieve_endpoint(self) -> str: + return self._api_url(RISK_INTELLIGENCE_RETRIEVE_PATH) + + @staticmethod + def _create_response_with_error( + raw_response: Any, + default_error_detail: Exception, + response_model: Type[ResponseModelT], + ) -> ResponseModelT: if not isinstance(raw_response, dict): raw_response = {} - error_code = "decode_response_failed" - else: - error_code = raw_response.get("error", {}).get("error_code") - error_detail = raw_response.get("error", {}).get( - "details", str(default_error_detail) + error_payload = raw_response.get("error", {}) + error_code = error_payload.get( + "error_code", DECODE_RESPONSE_FAILED_INTERNAL_ERROR_CODE ) - return FriendlyCaptchaResponse( + error_detail = error_payload.get("detail", str(default_error_detail)) + + return response_model( success=raw_response.get("success", False), error=Error( - error_code=error_code if error_code else "", - detail=error_detail if error_detail else str(default_error_detail), + error_code=error_code, + detail=error_detail, ), ) @staticmethod - def _is_client_error(error: Union[Error, None]): - return ( - error is not None and error.error_code in NON_STRICT_ERROR_CODES - ) # TODO: this could be O(1) + def _normalize_error_code(error_code: Union[str, DefaultErrorCodes]) -> str: + if isinstance(error_code, DefaultErrorCodes): + return error_code.value + return str(error_code) + + def _is_client_error(self, error: Union[Error, None]): + if error is None: + return False + return self._normalize_error_code(error.error_code) in NON_STRICT_ERROR_CODES @staticmethod def _get_current_version(): my_version = "0.0.0" try: - import pkg_resources + from importlib.metadata import version - my_version = pkg_resources.get_distribution( - "friendly-captcha-client" - ).version + my_version = version("friendly-captcha-client") except Exception: pass return my_version - def _process_response(self, response) -> (FriendlyCaptchaResponse, int): - """Process the API response and validate its structure. + def _process_response( + self, response: requests.Response, response_model: Type[ResponseModelT] + ) -> Tuple[ResponseModelT, int]: + """Process and validate an API response payload. Args: response (requests.Response): The API response. + response_model: The pydantic model used for response validation. Returns: - tuple: A tuple containing the FriendlyResponse object and the status code. + tuple: A tuple containing the parsed response model and the status code. """ + raw_response: Any = {} + + try: + raw_response = response.json() + except Exception as e: + if self.verbose: + self.logger.error("Error decoding API JSON response: %s", e) + parsed_response = self._create_response_with_error({}, e, response_model) + return parsed_response, response.status_code + try: - friendly_response = FriendlyCaptchaResponse.model_validate(response.json()) + parsed_response = response_model.model_validate(raw_response) except ValidationError as e: if self.verbose: - self.logger.error("Error in validating friendly response: %s", e) - friendly_response = self._create_friendly_response_with_error( - response.json(), e + self.logger.error("Error validating API response: %s", e) + parsed_response = self._create_response_with_error( + raw_response, e, response_model ) except Exception as e: if self.verbose: - self.logger.error("Error parsing friendly response: %s", e) - friendly_response = self._create_friendly_response_with_error( - response.json(), e + self.logger.error("Error parsing API response: %s", e) + parsed_response = self._create_response_with_error( + raw_response, e, response_model ) - return friendly_response, response.status_code + + return parsed_response, response.status_code def _is_loose_verification_available( self, status_code: int, error: Union[Error, None] @@ -206,22 +204,31 @@ def _is_loose_verification_available( ) def _is_error_loose(self, error, status_code): + error_code = self._normalize_error_code(error.error_code) + # known error where we allow loose verification if ( - any( - error.error_code == _error.value - for _error in self._non_strict_error_code - ) + error_code in self._non_strict_error_code or all( # unknown errors where we allow loose verification - error.error_code != _error.value for _error in DefaultErrorCodes + error_code != _error.value for _error in DefaultErrorCodes ) and status_code in [200, 500] ): return True return False - def _handle_api_response(self, response: requests.request) -> FriendlyCaptchaResult: - """Handle the API response and determine the success status. + @staticmethod + def _is_decode_response_failed(error: Union[Error, None]) -> bool: + return ( + error is not None + and FriendlyCaptchaClient._normalize_error_code(error.error_code) + == DECODE_RESPONSE_FAILED_INTERNAL_ERROR_CODE + ) + + def _handle_verify_captcha_response( + self, response: requests.request + ) -> FriendlyCaptchaResult: + """Handle the verify captcha API response and determine the success status. Args: response (requests.Response): The API response. @@ -229,18 +236,16 @@ def _handle_api_response(self, response: requests.request) -> FriendlyCaptchaRes Returns: FriendlyCaptchaResult: The processed result from the API response. """ - friendly_response, status_code = self._process_response(response) + friendly_response, status_code = self._process_response( + response, FriendlyCaptchaResponse + ) was_able_to_verify = status_code == 200 - if was_able_to_verify and friendly_response.error is not None: - if ( - friendly_response.error.error_code - == DECODE_RESPONSE_FAILED_INTERNAL_ERROR_CODE - ): - was_able_to_verify = False - - # and not ((friendly_response.error is None) and friendly_response.error.error_code != "unknown_error_code") + if was_able_to_verify and self._is_decode_response_failed( + friendly_response.error + ): + was_able_to_verify = False friendly_result = FriendlyCaptchaResult( should_accept=self._is_loose_verification_available( @@ -254,6 +259,32 @@ def _handle_api_response(self, response: requests.request) -> FriendlyCaptchaRes return friendly_result + def _handle_risk_intelligence_retrieve_response( + self, response: requests.request + ) -> RiskIntelligenceRetrieveResult: + """Handle the risk intelligence retrieve API response and determine the success status. + + Args: + response (requests.Response): The API response. + + Returns: + RiskIntelligenceRetrieveResult: The processed result from the API response. + """ + retrieve_response, status_code = self._process_response( + response, RiskIntelligenceRetrieveResponse + ) + + decode_response_failed = self._is_decode_response_failed( + retrieve_response.error + ) + + return RiskIntelligenceRetrieveResult( + was_able_to_retrieve=(status_code == 200 and not decode_response_failed), + is_client_error=(status_code != 200 and not decode_response_failed), + data=retrieve_response.data, + error=retrieve_response.error, + ) + def verify_captcha_response( self, captcha_response: str, timeout: int = 10 ) -> FriendlyCaptchaResult: @@ -286,4 +317,39 @@ def verify_captcha_response( }, timeout=timeout, ) - return self._handle_api_response(response) + return self._handle_verify_captcha_response(response) + + def retrieve_risk_intelligence( + self, token: str, timeout: int = 10 + ) -> RiskIntelligenceRetrieveResult: + """Retrieve risk intelligence data for a risk intelligence token. + + Refer to the official documentation for more details: + https://developer.friendlycaptcha.com/docs/api/endpoints/risk-intelligence-retrieve + + Args: + token (str): The risk intelligence token to retrieve. + timeout (int, optional): The request timeout in seconds. Defaults to 10. + + Returns: + RiskIntelligenceRetrieveResult: The processed result from the API response. + """ + if not isinstance(token, str): + return RiskIntelligenceRetrieveResult( + was_able_to_retrieve=False, + ) + + print(f"friendly-captcha-python@{self._get_current_version()}") + + response = requests.post( + url=self.risk_intelligence_retrieve_endpoint, + json={"token": token}, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "X-Api-Key": self.api_key, + "Frc-Sdk": f"friendly-captcha-python@{self._get_current_version()}", + }, + timeout=timeout, + ) + return self._handle_risk_intelligence_retrieve_response(response) diff --git a/friendly_captcha_client/schemas.py b/friendly_captcha_client/schemas.py new file mode 100644 index 0000000..cc0e228 --- /dev/null +++ b/friendly_captcha_client/schemas.py @@ -0,0 +1,135 @@ +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, field_validator, model_validator + +from friendly_captcha_client.schemas_risk_intelligence import RiskIntelligenceData + +DECODE_RESPONSE_FAILED_INTERNAL_ERROR_CODE = "decode_response_failed" +NON_STRICT_ERROR_CODES = [ + "auth_required", + "auth_invalid", + "sitekey_invalid", + "response_missing", + "bad_request", + "token_missing", + "token_expired", + "request_failed_due_to_client_error", + "client_error", +] + + +class DefaultErrorCodes(str, Enum): + AUTH_REQUIRED = "auth_required" # 401 + AUTH_INVALID = "auth_invalid" # 401 + SITEKEY_INVALID = "sitekey_invalid" # 400 + RESPONSE_MISSING = "response_missing" # 400 + TOKEN_MISSING = "token_missing" # 400 + TOKEN_EXPIRED = "token_expired" # 400 + BAD_REQUEST = "bad_request" # 400 + RESPONSE_INVALID = "response_invalid" # 200 + RESPONSE_TIMEOUT = "response_timeout" # 200 + RESPONSE_DUPLICATE = "response_duplicate" # 200 + CLIENT_ERROR = "request_failed_due_to_client_error" + + @staticmethod + def contains(value: str) -> bool: + return value in DefaultErrorCodes._value2member_map_ + + +class Error(BaseModel): + error_code: str + detail: str + + @field_validator("error_code") + def validate_error_code(cls, v: str): + """Validate and convert the error code to its enum representation if it exists.""" + if DefaultErrorCodes.contains(v): + return DefaultErrorCodes(v) + return v or DECODE_RESPONSE_FAILED_INTERNAL_ERROR_CODE + + @field_validator("detail") + def validate_detail(cls, v: str): + """Return the error detail or a default message if not provided.""" + return v or "Unknown error detail" + + +class VerifyResponseChallengeData(BaseModel): + """Challenge is the data found in the challenge field of a VerifyResponse. + + It contains information about the challenge that was solved. + """ + + timestamp: str + origin: str + + +class VerifyResponseData(BaseModel): + """VerifyResponseData is the data found in the data field of a VerifyResponse.""" + + # Unique identifier for this siteverify call. + event_id: str + # Information about the challenge that was solved. + challenge: VerifyResponseChallengeData + # Risk information about the solver of the captcha. + # This may be None if risk intelligence is not enabled for your Friendly Captcha account. + risk_intelligence: Optional[RiskIntelligenceData] = None + + +class FriendlyCaptchaResponse(BaseModel): + success: bool + data: Optional[VerifyResponseData] = None + error: Optional[Error] = None + + @model_validator(mode="after") + def check_data_or_error(cls, values): + if values.success and values.error: + raise ValueError("If success is True, error should not be set.") + if not values.success and values.data: + raise ValueError("If success is False, data should not be set.") + return values + + +class FriendlyCaptchaResult(BaseModel): + should_accept: bool + was_able_to_verify: bool + data: Optional[VerifyResponseData] = None + error: Optional[Error] = None + is_client_error: bool = False + + +class RiskIntelligenceRetrieveResponseDetails(BaseModel): + # Timestamp when the token was generated. + timestamp: str + # Timestamp when the token expires. + expires_at: str + # Number of times the token has been used. + num_uses: int + + +class RiskIntelligenceRetrieveResponseData(BaseModel): + # Risk information extracted from the retrieve token. + risk_intelligence: Optional[RiskIntelligenceData] = None + # Metadata about the token and retrieval operation. + details: RiskIntelligenceRetrieveResponseDetails + + +class RiskIntelligenceRetrieveResponse(BaseModel): + success: bool + data: Optional[RiskIntelligenceRetrieveResponseData] = None + error: Optional[Error] = None + + @model_validator(mode="after") + def check_data_or_error(cls, values): + if values.success and values.error: + raise ValueError("If success is True, error should not be set.") + if not values.success and values.data: + raise ValueError("If success is False, data should not be set.") + return values + + +class RiskIntelligenceRetrieveResult(BaseModel): + was_able_to_retrieve: bool + data: Optional[RiskIntelligenceRetrieveResponseData] = None + error: Optional[Error] = None + is_client_error: bool = False diff --git a/friendly_captcha_client/schemas_risk_intelligence.py b/friendly_captcha_client/schemas_risk_intelligence.py new file mode 100644 index 0000000..a9c5d53 --- /dev/null +++ b/friendly_captcha_client/schemas_risk_intelligence.py @@ -0,0 +1,388 @@ +from pydantic import BaseModel, Field +from typing import Optional + + +class RiskScoresData(BaseModel): + """RiskScoresData summarizes the entire risk intelligence assessment into scores per category. + + Available when the Risk Scores module is enabled for your account. + None when the Risk Scores module is not enabled for your account. + """ + + # Overall risk score combining all signals. + overall: int + # Network-related risk score. Captures likelihood of automation/malicious activity based on + # IP address, ASN, reputation, geolocation, past abuse from this network, and other network signals. + network: int + # Browser-related risk score. Captures likelihood of automation, malicious activity or browser spoofing based on + # user agent consistency, automation traces, past abuse, and browser characteristics. + browser: int + + +class NetworkAutonomousSystemData(BaseModel): + """NetworkAutonomousSystemData contains information about the AS that owns the IP. + + Available when the IP Intelligence module is enabled for your account. + None when the IP Intelligence module is not enabled for your account. + """ + + # Autonomous System Number (ASN) identifier. + # Example: 3209 for Vodafone GmbH + number: int + # Name of the autonomous system. This is usually a short name or handle. + # Example: "VODANET" + name: str + # Company is the organization name that owns the ASN. + # Example: "Vodafone GmbH" + company: str + # Description of the company that owns the ASN. + # Example: "Provides mobile and fixed broadband and telecommunication services to consumers and businesses." + description: str + # Domain name associated with the ASN. + # Example: "vodafone.de" + domain: str + # Two-letter ISO 3166-1 alpha-2 country code where the ASN is registered. + # Example: "DE" + country: str + # Regional Internet Registry that allocated the ASN. + # Example: "RIPE" + rir: str + # IP route associated with the ASN in CIDR notation. + # Example: "88.64.0.0/12" + route: str + # Autonomous system type. + # Example: "isp" + type: str + + +class NetworkGeolocationCountryData(BaseModel): + """NetworkGeolocationCountryData contains detailed country data.""" + + # Two-letter ISO 3166-1 alpha-2 country code. + # Example: "DE" + iso2: str + # Three-letter ISO 3166-1 alpha-3 country code. + # Example: "DEU" + iso3: str + # English name of the country. + # Example: "Germany" + name: str + # Native name of the country. + # Example: "Deutschland" + name_native: str + # Major world region. + # Example: "Europe" + region: str + # More specific world region. + # Example: "Western Europe" + subregion: str + # ISO 4217 currency code. + # Example: "EUR" + currency: str + # Full name of the currency. + # Example: "Euro" + currency_name: str + # International dialing code. + # Example: "49" + phone_code: str + # Name of the capital city. + # Example: "Berlin" + capital: str + + +class NetworkGeolocationData(BaseModel): + """NetworkGeolocationData contains geographic location of the IP address. + + Available when the IP Intelligence module is enabled. + None when the IP Intelligence module is not enabled. + """ + + # Country information. + country: NetworkGeolocationCountryData + # City name. Empty string if unknown. + # Example: "Eschborn" + city: str + # State, region, or province. Empty string if unknown. + # Example: "Hessen" + state: str + + +class NetworkAbuseContactData(BaseModel): + """NetworkAbuseContactData contains contact details for reporting abuse. + + Available when the IP Intelligence module is enabled. + None when the IP Intelligence module is not enabled. + """ + + # Postal address of the abuse contact. + # Example: "Vodafone GmbH, Campus Eschborn, Duesseldorfer Strasse 15, D-65760 Eschborn, Germany" + address: str + # Name of the abuse contact person or team. + # Example: "Vodafone Germany IP Core Backbone" + name: str + # Abuse contact email address. + # Example: "abuse.de@vodafone.com" + email: str + # Abuse contact phone number. + # Example: "+49 6196 52352105" + phone: str + + +class NetworkAnonymizationData(BaseModel): + """NetworkAnonymizationData contains detection of VPNs, proxies, and anonymization services. + + Available when the Anonymization Detection module is enabled. + None when the Anonymization Detection module is not enabled. + """ + + # Likelihood that the IP is from a VPN service. + vpn_score: int + # Likelihood that the IP is from a proxy service. + proxy_score: int + # Whether the IP is a Tor exit node. + tor: bool + # Whether the IP is from iCloud Private Relay. + icloud_private_relay: bool + + +class NetworkData(BaseModel): + """NetworkData contains information about the network.""" + + # IP address used when requesting the challenge. + # Example: "88.64.4.22" + ip: str + # Autonomous System information. + # + # Available when the IP Intelligence module is enabled. + # None when the IP Intelligence module is not enabled. + as_: Optional[NetworkAutonomousSystemData] = Field(default=None, alias="as") + # Geolocation information. + # + # Available when the IP Intelligence module is enabled. + # None when the IP Intelligence module is not enabled. + geolocation: Optional[NetworkGeolocationData] = None + # Abuse contact information. + # + # Available when the IP Intelligence module is enabled. + # None when the IP Intelligence module is not enabled. + abuse_contact: Optional[NetworkAbuseContactData] = None + # IP masking/anonymization information. + # + # Available when the Anonymization Detection module is enabled. + # None when the Anonymization Detection module is not enabled. + anonymization: Optional[NetworkAnonymizationData] = None + + +class ClientTimeZoneData(BaseModel): + """ClientTimeZoneData contains IANA time zone data. + + Available when the Browser Identification module is enabled. + None when the Browser Identification module is not enabled. + """ + + # IANA time zone name reported by the browser. + # Example: "America/New_York" or "Europe/Berlin" + name: str + # Two-letter ISO 3166-1 alpha-2 country code derived from the time zone. + # "XU" if timezone is missing or cannot be mapped to a country (e.g., "Etc/UTC"). + # Example: "US" or "DE" + country_iso2: str + + +class ClientBrowserData(BaseModel): + """ClientBrowserData contains detected browser details. + + Available when the Browser Identification module is enabled. + None when the Browser Identification module is not enabled. + """ + + # Unique browser identifier. Empty string if browser could not be identified. + # Example: "firefox", "chrome", "chrome_android", "edge", "safari", "safari_ios", "webview_ios" + id: str + # Human-readable browser name. Empty string if browser could not be identified. + # Example: "Firefox", "Chrome", "Edge", "Safari", "Safari on iOS", "WebView on iOS" + name: str + # Browser version name. Assumed to be the most recent release matching the signature if exact version unknown. Empty if unknown. + # Example: "146.0" or "16.5" + version: str + # Release date of the browser version in "YYYY-MM-DD" format. Empty string if unknown. + # Example: "2026-01-28" + release_date: str + + +class ClientBrowserEngineData(BaseModel): + """ClientBrowserEngineData contains detected rendering engine details. + + Available when the Browser Identification module is enabled. + None when the Browser Identification module is not enabled. + """ + + # Unique rendering engine identifier. Empty string if engine could not be identified. + # Example: "gecko", "blink", "webkit" + id: str + # Human-readable engine name. Empty string if engine could not be identified. + # Example: "Gecko", "Blink", "WebKit" + name: str + # Rendering engine version. Assumed to be the most recent release matching the signature if exact version unknown. Empty if unknown. + # Example: "146.0" or "16.5" + version: str + + +class ClientDeviceData(BaseModel): + """ClientDeviceData contains detected device details. + + Available when the Browser Identification module is enabled. + None when the Browser Identification module is not enabled. + """ + + # Device type. + # Example: "desktop", "mobile", "tablet" + type: str + # Device brand. + # Example: "Apple", "Samsung", "Google" + brand: str + # Device model name. + # Example: "iPhone 17", "Galaxy S21 (SM-G991B)", "Pixel 10" + model: str + + +class ClientOSData(BaseModel): + """ClientOSData contains detected OS details. + + Available when the Browser Identification module is enabled. + None when the Browser Identification module is not enabled. + """ + + # Unique operating system identifier. Empty string if OS could not be identified. + # Example: "windows", "macos", "ios", "android", "linux" + id: str + # Human-readable operating system name. Empty string if OS could not be identified. + # Example: "Windows", "macOS", "iOS", "Android", "Linux" + name: str + # Operating system version. + # Example: "10", "11.2.3", "14.4" + version: str + + +class TLSSignatureData(BaseModel): + """TLSSignatureData contains TLS client hello signatures. + + Available when the Bot Detection module is enabled. + None when the Bot Detection module is not enabled. + """ + + # JA3 hash. + # Example: "d87a30a5782a73a83c1544bb06332780" + ja3: str + # JA3N hash. + # Example: "28ecc2d2875b345cecbb632b12d8c1e0" + ja3n: str + # JA4 signature. + # Example: "t13d1516h2_8daaf6152771_02713d6af862" + ja4: str + + +class ClientAutomationKnownBotData(BaseModel): + """ClientAutomationKnownBotData contains detected known bot details.""" + + # Whether a known bot was detected. + detected: bool + # Bot identifier. Empty if no bot detected. + # Example: "googlebot", "bingbot", "chatgpt" + id: str + # Human-readable bot name. Empty if no bot detected. + # Example: "Googlebot", "Bingbot", "ChatGPT" + name: str + # Bot type classification. Empty if no bot detected. + type: str + # Link to bot documentation. Empty if no bot detected. + # Example: "https://developers.google.com/search/docs/crawling-indexing/googlebot" + url: str + + +class ClientAutomationToolData(BaseModel): + """ClientAutomationToolData contains detected automation tool details.""" + + # Whether an automation tool was detected. + detected: bool + # Automation tool identifier. Empty if no tool detected. + # Example: "puppeteer", "selenium", "playwright" + id: str + # Human-readable tool name. Empty if no tool detected. + # Example: "Puppeteer", "Selenium WebDriver", "Playwright" + name: str + # Automation tool type. Empty if no tool detected. + type: str + + +class ClientAutomationData(BaseModel): + """ClientAutomationData contains information about detected automation. + + Available when the Bot Detection module is enabled. + None when the Bot Detection module is not enabled. + """ + + # Detected automation tool information. + automation_tool: ClientAutomationToolData + # Detected known bot information. + known_bot: ClientAutomationKnownBotData + + +class ClientData(BaseModel): + """ClientData contains information about the user agent and device.""" + + # User-Agent HTTP header value. + # Example: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:146.0) Gecko/20100101 Firefox/146.0" + header_user_agent: str + # Time zone information. + # + # Available when the Browser Identification module is enabled. + # None when the Browser Identification module is not enabled. + time_zone: Optional[ClientTimeZoneData] = None + # Browser information. + # + # Available when the Browser Identification module is enabled. + # None when the Browser Identification module is not enabled. + browser: Optional[ClientBrowserData] = None + # Browser engine information. + # + # Available when the Browser Identification module is enabled. + # None when the Browser Identification module is not enabled. + browser_engine: Optional[ClientBrowserEngineData] = None + # Device information. + # + # Available when the Browser Identification module is enabled. + # None when the Browser Identification module is not enabled. + device: Optional[ClientDeviceData] = None + # OS information. + # + # Available when the Browser Identification module is enabled. + # None when the Browser Identification module is not enabled. + os: Optional[ClientOSData] = None + # TLS signatures. + # + # Available when the Bot Detection module is enabled. + # None when the Bot Detection module is not enabled. + tls_signature: Optional[TLSSignatureData] = None + # Automation detection data. + # + # Available when the Bot Detection module is enabled. + # None when the Bot Detection module is not enabled. + automation: Optional[ClientAutomationData] = None + + +class RiskIntelligenceData(BaseModel): + """RiskIntelligenceData contains all risk intelligence information. + + Field availability depends on enabled modules. + """ + + # Risk scores from various signals, these summarize the risk intelligence assessment. + # + # Available when the Risk Scores module is enabled. + # None when the Risk Scores module is not enabled. + risk_scores: Optional[RiskScoresData] = None + # Network-related risk intelligence. + network: NetworkData + # Client/device risk intelligence. + client: ClientData diff --git a/integration_tests/test_python_sdk.py b/integration_tests/test_python_sdk.py index 64f9d15..d8445a5 100644 --- a/integration_tests/test_python_sdk.py +++ b/integration_tests/test_python_sdk.py @@ -1,16 +1,20 @@ -import requests +import json import pytest +import requests -from friendly_captcha_client.client import FriendlyCaptchaClient +from friendly_captcha_client.client import ( + FriendlyCaptchaClient, + RiskIntelligenceRetrieveResult, +) +from friendly_captcha_client.schemas import ( + FriendlyCaptchaResponse, + RiskIntelligenceRetrieveResponse, +) MOCK_SERVER_URL = "http://localhost:1090" -API_ENDPOINT = "/api/v2/captcha/siteverify" -TEST_ENDPOINT = "/api/v1/tests" -HEADERS = { - "Content-Type": "application/json", - "Accept": "application/json", - "X-Frc-Sdk": "friendly-captcha-python-sdk@99.99.99", -} + +CAPTCHA_SITEVERIFY_TESTS_ENDPOINT = "/api/v1/captcha/siteverifyTests" +RISK_INTELLIGENCE_RETRIEVE_TESTS_ENDPOINT = "/api/v1/riskIntelligence/retrieveTests" def fetch_test_cases_from_server(endpoint: str): @@ -35,17 +39,19 @@ def is_mock_server_running(url): @pytest.mark.skipif( - not is_mock_server_running(MOCK_SERVER_URL + TEST_ENDPOINT), + not is_mock_server_running(MOCK_SERVER_URL + CAPTCHA_SITEVERIFY_TESTS_ENDPOINT), reason="Mock server is not running, skipping integration test.", ) -def test_python_sdk(): - test_data = fetch_test_cases_from_server(MOCK_SERVER_URL + TEST_ENDPOINT) +def test_python_sdk_captcha_siteverify(): + test_data = fetch_test_cases_from_server( + MOCK_SERVER_URL + CAPTCHA_SITEVERIFY_TESTS_ENDPOINT + ) for test in test_data["tests"]: frc_client = FriendlyCaptchaClient( api_key="FRC_APIKEY", sitekey="FRC_SITEKEY", - siteverify_endpoint=f"{MOCK_SERVER_URL}{API_ENDPOINT}", + api_endpoint=MOCK_SERVER_URL, strict=bool(test["strict"]), ) @@ -62,6 +68,116 @@ def test_python_sdk(): assert ( response.is_client_error == test["expectation"]["is_client_error"] ), f"Test {test['name']} failed [is client error]!" + + # When verification succeeded, compare data to expected siteverify response + if response.data is not None: + raw = test.get("siteverify_response") + if raw is not None: + data = json.loads(raw) if isinstance(raw, str) else raw + if isinstance(data, dict) and data.get("success"): + expected_response = FriendlyCaptchaResponse.model_validate(data) + exp = expected_response.data + res = response.data + assert exp is not None, "Expected response data is missing" + + assert ( + exp.event_id == res.event_id + ), f"Test {test['name']}: Event ID does not match expected value" + assert ( + exp.challenge == res.challenge + ), f"Test {test['name']}: Challenge data does not match expected value" + assert ( + exp.risk_intelligence == res.risk_intelligence + ), f"Test {test['name']}: Risk Intelligence data does not match expected value" + + # Check specific fields + if ( + exp.risk_intelligence is not None + and res.risk_intelligence is not None + ): + assert ( + exp.risk_intelligence.client.header_user_agent + == res.risk_intelligence.client.header_user_agent + ), f"Test {test['name']}: header_user_agent does not match" + exp_browser = exp.risk_intelligence.client.browser + res_browser = res.risk_intelligence.client.browser + if exp_browser is not None and res_browser is not None: + assert ( + exp_browser.id == res_browser.id + ), f"Test {test['name']}: client.browser.id does not match" + print(f"Tests {test['name']} passed!") print("All tests passed!") + + +@pytest.mark.skipif( + not is_mock_server_running( + MOCK_SERVER_URL + RISK_INTELLIGENCE_RETRIEVE_TESTS_ENDPOINT + ), + reason="Mock server is not running, skipping integration test.", +) +def test_python_sdk_risk_intelligence_retrieve(): + test_data = fetch_test_cases_from_server( + MOCK_SERVER_URL + RISK_INTELLIGENCE_RETRIEVE_TESTS_ENDPOINT + ) + + for test in test_data["tests"]: + frc_client = FriendlyCaptchaClient( + api_key="FRC_APIKEY", + sitekey="FRC_SITEKEY", + api_endpoint=MOCK_SERVER_URL, + strict=False, + ) + + response: RiskIntelligenceRetrieveResult = ( + frc_client.retrieve_risk_intelligence( + token=test["token"], + timeout=10, + ) + ) + + assert ( + response.was_able_to_retrieve == test["expectation"]["was_able_to_retrieve"] + ), f"Test {test['name']} failed [was able to retrieve]!" + assert ( + response.is_client_error == test["expectation"]["is_client_error"] + ), f"Test {test['name']} failed [is client error]!" + + if response.data is not None: + raw = test.get("retrieve_response") + if raw is not None: + data = json.loads(raw) if isinstance(raw, str) else raw + if isinstance(data, dict) and data.get("success"): + expected_response = RiskIntelligenceRetrieveResponse.model_validate( + data + ) + exp = expected_response.data + res = response.data + assert exp is not None, "Expected retrieve data is missing" + + assert ( + exp.risk_intelligence == res.risk_intelligence + ), f"Test {test['name']}: Risk Intelligence data does not match expected value" + assert ( + exp.details == res.details + ), f"Test {test['name']}: Retrieve details do not match expected value" + + if ( + exp.risk_intelligence is not None + and res.risk_intelligence is not None + ): + exp_client = exp.risk_intelligence.client + res_client = res.risk_intelligence.client + if exp_client is not None and res_client is not None: + assert ( + exp_client.header_user_agent + == res_client.header_user_agent + ), f"Test {test['name']}: header_user_agent does not match" + + exp_browser = exp_client.browser + res_browser = res_client.browser + if exp_browser is not None and res_browser is not None: + assert ( + exp_browser.id == res_browser.id + ), f"Test {test['name']}: client.browser.id does not match" diff --git a/pyproject.toml b/pyproject.toml index 6e85877..387ea63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "friendly-captcha-client" -version = "0.1.0" +version = "0.2.0" description = "A client for Friendly Captcha." authors = [ "Antal Nagy ", @@ -16,7 +16,7 @@ repository = "https://github.com/FriendlyCaptcha/friendly-captcha-python" keywords = ["Friendly Captcha Client", "Captcha"] [tool.poetry.dependencies] -python = "^3.6" +python = "^3.10" requests = "2.31.0" pydantic = "2.1.1" diff --git a/tests/conftest.py b/tests/conftest.py index 4adb946..d3f6469 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ def client(): return FriendlyCaptchaClient( api_key=API_KEY, sitekey=SITEKEY, - siteverify_endpoint="http://localhost", + api_endpoint="http://localhost", strict=False, verbose=True, ) @@ -22,7 +22,7 @@ def strict_client(): return FriendlyCaptchaClient( api_key=API_KEY, sitekey=SITEKEY, - siteverify_endpoint="http://localhost", + api_endpoint="http://localhost", strict=True, verbose=True, ) diff --git a/tests/test_friendly_client.py b/tests/test_friendly_client.py index c157f3c..a9500ad 100644 --- a/tests/test_friendly_client.py +++ b/tests/test_friendly_client.py @@ -3,7 +3,11 @@ import pytest import requests_mock -from friendly_captcha_client.client import DefaultErrorCodes, FriendlyCaptchaResult +from friendly_captcha_client.client import ( + DefaultErrorCodes, + FriendlyCaptchaResult, + RiskIntelligenceRetrieveResult, +) # Mocked responses for different scenarios CAPTCHA_RESPONSE = "test_captcha_response" @@ -58,32 +62,130 @@ } -def test_shorthand_siteverify_endpoint(): +def test_default_api_endpoint(): from friendly_captcha_client.client import FriendlyCaptchaClient - # test default client = FriendlyCaptchaClient( api_key="FRC_APIKEY", sitekey="FRC_SITE_KEY", ) - + assert client.api_endpoint == "https://global.frcapi.com" assert ( client.siteverify_endpoint == "https://global.frcapi.com/api/v2/captcha/siteverify" ) + assert ( + client.risk_intelligence_retrieve_endpoint + == "https://global.frcapi.com/api/v2/riskIntelligence/retrieve" + ) + + +def test_shorthand_api_endpoint(): + from friendly_captcha_client.client import FriendlyCaptchaClient + + client = FriendlyCaptchaClient( + api_key="FRC_APIKEY", + sitekey="FRC_SITE_KEY", + api_endpoint="eu", + ) + + assert client.api_endpoint == "https://eu.frcapi.com" + assert ( + client.siteverify_endpoint == "https://eu.frcapi.com/api/v2/captcha/siteverify" + ) + assert ( + client.risk_intelligence_retrieve_endpoint + == "https://eu.frcapi.com/api/v2/riskIntelligence/retrieve" + ) + + +def test_api_endpoint_must_not_be_empty(): + from friendly_captcha_client.client import FriendlyCaptchaClient + + with pytest.raises(ValueError, match="api_endpoint must not be empty"): + FriendlyCaptchaClient( + api_key="FRC_APIKEY", + sitekey="FRC_SITE_KEY", + api_endpoint="", + ) + + +def test_siteverify_endpoint_is_deprecated_shorthand(): + from friendly_captcha_client.client import FriendlyCaptchaClient - # test 'eu' shorthand client = FriendlyCaptchaClient( api_key="FRC_APIKEY", sitekey="FRC_SITE_KEY", siteverify_endpoint="eu", ) + assert client.api_endpoint == "https://eu.frcapi.com" assert ( client.siteverify_endpoint == "https://eu.frcapi.com/api/v2/captcha/siteverify" ) +def test_siteverify_endpoint_is_deprecated_and_strips_path(): + from friendly_captcha_client.client import FriendlyCaptchaClient + + client = FriendlyCaptchaClient( + api_key="FRC_APIKEY", + sitekey="FRC_SITE_KEY", + siteverify_endpoint="https://eu.frcapi.com/api/v2/captcha/siteverify", + ) + + assert client.api_endpoint == "https://eu.frcapi.com" + assert ( + client.siteverify_endpoint == "https://eu.frcapi.com/api/v2/captcha/siteverify" + ) + assert ( + client.risk_intelligence_retrieve_endpoint + == "https://eu.frcapi.com/api/v2/riskIntelligence/retrieve" + ) + + +def test_siteverify_endpoint_empty_is_error(): + from friendly_captcha_client.client import FriendlyCaptchaClient + + with pytest.raises(ValueError, match="siteverify_endpoint must not be empty"): + FriendlyCaptchaClient( + api_key="FRC_APIKEY", + sitekey="FRC_SITE_KEY", + siteverify_endpoint="", + ) + + +def test_siteverify_endpoint_invalid_url_is_error(): + from friendly_captcha_client.client import FriendlyCaptchaClient + + with pytest.raises( + ValueError, + match="invalid siteverify_endpoint URL: expected fully qualified URL", + ): + FriendlyCaptchaClient( + api_key="FRC_APIKEY", + sitekey="FRC_SITE_KEY", + siteverify_endpoint="not-a-url", + ) + + +def test_api_endpoint_takes_precedence_over_deprecated_siteverify_endpoint(): + from friendly_captcha_client.client import FriendlyCaptchaClient + + client = FriendlyCaptchaClient( + api_key="FRC_APIKEY", + sitekey="FRC_SITE_KEY", + api_endpoint="global", + siteverify_endpoint="https://eu.frcapi.com/api/v2/captcha/siteverify", + ) + + assert client.api_endpoint == "https://global.frcapi.com" + assert ( + client.siteverify_endpoint + == "https://global.frcapi.com/api/v2/captcha/siteverify" + ) + + # Mock the actual API post request to return the mock response def mock_post_request(*args, **kwargs): json_data = kwargs["json"] @@ -222,3 +324,91 @@ def test_verify_captcha_response_errors_strict( ) assert result.should_accept == expected_should_accept assert result.was_able_to_verify == expected_was_able_to_verify + + +def test_retrieve_risk_intelligence_success(client): + retrieve_response = { + "success": True, + "data": { + "risk_intelligence": { + "network": {"ip": "127.0.0.1"}, + "client": { + "header_user_agent": "Mozilla/5.0", + "browser": { + "id": "chrome", + "name": "Chrome", + "version": "91.0.4472.124", + "release_date": "2021-06-24", + }, + }, + }, + "details": { + "timestamp": "2023-08-04T13:01:25Z", + "expires_at": "2023-08-04T13:06:25Z", + "num_uses": 1, + }, + }, + } + + with requests_mock.Mocker() as m: + m.post( + client.risk_intelligence_retrieve_endpoint, + json=retrieve_response, + status_code=200, + ) + + result: RiskIntelligenceRetrieveResult = client.retrieve_risk_intelligence( + "token" + ) + assert result.was_able_to_retrieve is True + assert result.is_client_error is False + assert result.data is not None + assert result.data.details.num_uses == 1 + assert result.data.risk_intelligence is not None + assert result.data.risk_intelligence.client is not None + assert result.data.risk_intelligence.client.browser is not None + assert result.data.risk_intelligence.client.browser.id == "chrome" + + +@pytest.mark.parametrize( + "error_code", + [ + DefaultErrorCodes.AUTH_REQUIRED, + DefaultErrorCodes.AUTH_INVALID, + DefaultErrorCodes.BAD_REQUEST, + DefaultErrorCodes.TOKEN_MISSING, + DefaultErrorCodes.TOKEN_EXPIRED, + ], +) +def test_retrieve_risk_intelligence_client_errors(client, error_code): + with requests_mock.Mocker() as m: + m.post( + client.risk_intelligence_retrieve_endpoint, + json={ + "success": False, + "error": {"error_code": error_code, "detail": ""}, + }, + status_code=400, + ) + + result: RiskIntelligenceRetrieveResult = client.retrieve_risk_intelligence( + "token" + ) + assert result.was_able_to_retrieve is False + assert result.is_client_error is True + + +def test_retrieve_risk_intelligence_bad_response_with_500(client): + with requests_mock.Mocker() as m: + m.post( + client.risk_intelligence_retrieve_endpoint, + text="Something went horribly wrong", + status_code=500, + headers={"Content-Type": "text/html"}, + ) + + result: RiskIntelligenceRetrieveResult = client.retrieve_risk_intelligence( + "token" + ) + assert result.was_able_to_retrieve is False + assert result.is_client_error is False