diff --git a/.github/workflows/test-and-deploy.yml b/.github/workflows/test-and-deploy.yml index 4db532e3..ac662d57 100644 --- a/.github/workflows/test-and-deploy.yml +++ b/.github/workflows/test-and-deploy.yml @@ -17,7 +17,7 @@ jobs: timeout-minutes: 20 strategy: matrix: - python-version: [ '2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11' ] + python-version: ['3.8', '3.9', '3.10', '3.11' ] env: DOCKER_LOGIN: ${{ secrets.DOCKER_USERNAME && secrets.DOCKER_AUTH_TOKEN }} steps: @@ -31,6 +31,11 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_AUTH_TOKEN }} + - name: Install Docker Compose + run: | + sudo apt-get update + sudo apt-get install -y docker-compose + - name: Build & Test run: make test-docker version=${{ matrix.python-version }} diff --git a/.gitignore b/.gitignore index 50464024..c7cb684f 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ example.pdf TODO.txt twilio.env prism* +**/.openapi-generator* \ No newline at end of file diff --git a/LICENSE b/LICENSE index 3154774a..d703157e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (C) 2023, Twilio SendGrid, Inc. +Copyright (C) 2024, Twilio SendGrid, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/requirements.txt b/requirements.txt index 0c34aafd..a923d392 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,8 @@ -Flask==1.1.2 +#Flask==1.1.2 +requests>=2.31.0 +#aiohttp>=3.9.4 +#aiohttp-retry>=2.8.3 + PyYAML>=4.2b1 python-http-client>=3.2.1 six==1.11.0 diff --git a/sendgrid/base/__init__.py b/sendgrid/base/__init__.py new file mode 100644 index 00000000..143f486c --- /dev/null +++ b/sendgrid/base/__init__.py @@ -0,0 +1 @@ +# __init__.py diff --git a/sendgrid/base/auth_strategy.py b/sendgrid/base/auth_strategy.py new file mode 100644 index 00000000..bf23ebdb --- /dev/null +++ b/sendgrid/base/auth_strategy.py @@ -0,0 +1,12 @@ +# Handle different of authentications, Currently sendgrid authenticate using apikey. +# class AuthStrategy: +# def authenticate(self): +# print('Not yet implemented') +# +# +# class ApiKeyAuthStrategy(AuthStrategy): +# def __init__(self, api_key): +# self.api_key = api_key +# print('init ApiKeyAuthStrategy') +# def authenticate(self, api_key): +# print(f"Authenticating {api_key} using Token Authentication.") diff --git a/sendgrid/base/client_base.py b/sendgrid/base/client_base.py new file mode 100644 index 00000000..387fba9b --- /dev/null +++ b/sendgrid/base/client_base.py @@ -0,0 +1,7 @@ +class ClientBase: + + def __init__(self): + print("Creating ClientBase class") + + def request(self): + print("Making request") diff --git a/sendgrid/base/url_builder.py b/sendgrid/base/url_builder.py new file mode 100644 index 00000000..49f682a6 --- /dev/null +++ b/sendgrid/base/url_builder.py @@ -0,0 +1,16 @@ +def build_url(url: str, region: str) -> str: + base_url = "https://api.sendgrid.com" + + if region and isinstance(region, str): + new_url = f"https://api.{region}.sendgrid.com" + else: + new_url = base_url + + # Ensure that there's a '/' before appending the url + if not new_url.endswith('/'): + new_url += '/' + + new_url += url.lstrip('/') + + return new_url + diff --git a/sendgrid/base/values.py b/sendgrid/base/values.py new file mode 100644 index 00000000..16032b11 --- /dev/null +++ b/sendgrid/base/values.py @@ -0,0 +1,13 @@ +from typing import Dict + +unset = object() + + +def of(d: Dict[str, object]) -> Dict[str, object]: + """ + Remove unset values from a dict. + + :param d: A dict to strip. + :return A dict with unset values removed. + """ + return {k: v for k, v in d.items() if v != unset} diff --git a/sendgrid/client.py b/sendgrid/client.py new file mode 100644 index 00000000..a04ec889 --- /dev/null +++ b/sendgrid/client.py @@ -0,0 +1,47 @@ +from typing import List, Optional +from sendgrid.http.http_client import SendgridHttpClient, HttpClient +from sendgrid.http.request import Request +from sendgrid.base.url_builder import build_url + +# class AuthStrategy: +# def authenticate(self): +# pass +# +# +# class ApiKeyAuthStrategy(AuthStrategy): +# def __init__(self, api_key): +# self.api_key = api_key +# +# def authenticate( +# self, +# headers: Optional[Dict[str, str]] = None +# ): +# headers["Authorization"] = f"Bearer {self.api_key}" +# + + +class Client: + def __init__( + self, + api_key: str, + region: Optional[str] = None, + edge: Optional[str] = None, + http_client: Optional[HttpClient] = None, + user_agent_extensions: Optional[List[str]] = None, + ): + self.api_key = api_key + self.region = region + self.edge = edge + self.user_agent_extensions = user_agent_extensions or [] + self.http_client: SendgridHttpClient = SendgridHttpClient() + + def send(self, request: Request): + url = build_url(request.url, self.region) + response = self.http_client.request( + method=request.method, + url=url, + data=request.data, + headers=request.headers, + api_key=self.api_key, + ) + return response \ No newline at end of file diff --git a/sendgrid/converters/__init__.py b/sendgrid/converters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sendgrid/converters/serialize.py b/sendgrid/converters/serialize.py new file mode 100644 index 00000000..57e36069 --- /dev/null +++ b/sendgrid/converters/serialize.py @@ -0,0 +1,43 @@ +from enum import Enum + +from enum import Enum + + +def to_serializable(obj): + if isinstance(obj, list): + return [ + to_serializable(item) for item in obj if item is not None + ] # Remove None from lists + elif isinstance(obj, dict): + return { + key: to_serializable(value) + for key, value in obj.items() + if value is not None + } # Remove None from dicts + elif hasattr(obj, "to_dict"): + return obj.to_dict() + elif isinstance(obj, Enum): + return obj.value + else: + return obj + + +def from_serializable(data, cls=None): + """ + Converts a dictionary or list into a class instance or a list of instances. + If `cls` is provided, it will instantiate the class using the dictionary values. + """ + if isinstance(data, list): + return [ + from_serializable(item, cls) for item in data + ] # Recursively handle lists + elif isinstance(data, dict): + if cls: + # If a class is provided, instantiate it using the dictionary + return cls(**{key: from_serializable(value) for key, value in data.items()}) + else: + return { + key: from_serializable(value) for key, value in data.items() + } # Recursively handle dicts + else: + return data # Return primitive types as is diff --git a/sendgrid/exceptions/__init__.py b/sendgrid/exceptions/__init__.py new file mode 100644 index 00000000..11e921cd --- /dev/null +++ b/sendgrid/exceptions/__init__.py @@ -0,0 +1,15 @@ +from typing import Any, Dict + + +class SendgridException(Exception): + pass + + +class ApiException(SendgridException): + def __init__(self, status_code: int, error: Any, headers: Dict[str, Any] = None): + self.status_code = status_code + self.error = error + self.headers = headers or {} + + def __str__(self): + return f"ApiException(status_code={self.status_code}, error={self.error}, headers={self.headers})" diff --git a/sendgrid/http/__init__.py b/sendgrid/http/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/sendgrid/http/__init__.py @@ -0,0 +1 @@ + diff --git a/sendgrid/http/http_client.py b/sendgrid/http/http_client.py new file mode 100644 index 00000000..5e802fca --- /dev/null +++ b/sendgrid/http/http_client.py @@ -0,0 +1,212 @@ +import logging +import os +from logging import Logger +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlencode + +from requests import Request, Session, hooks +from requests.adapters import HTTPAdapter + +from sendgrid.exceptions import SendgridException +from sendgrid.http.response import Response + +_logger = logging.getLogger("sendgrid.http_client") # TODO: Validate this logger + + +class HttpClient: + def __init__(self, logger: Logger, is_async: bool, timeout: Optional[float] = None): + self.logger = logger + self.is_async = is_async + + if timeout is not None and timeout <= 0: + raise ValueError(timeout) + self.timeout = timeout + + self._test_only_last_request: Optional[Request] = None + self._test_only_last_response: Optional[Response] = None + + """ + An abstract class representing an HTTP client. + """ + + def request( + self, + method: str, + uri: str, + params: Optional[Dict[str, object]] = None, + data: Optional[Dict[str, object]] = None, + headers: Optional[Dict[str, str]] = None, + auth: Optional[Tuple[str, str]] = None, + timeout: Optional[float] = None, + allow_redirects: bool = False, + ) -> Response: + """ + Make an HTTP request. + """ + raise SendgridException("HttpClient is an abstract class") + + def log_request(self, kwargs: Dict[str, Any]) -> None: + """ + Logs the HTTP request + """ + self.logger.info("-- BEGIN Twilio API Request --") + + if kwargs["params"]: + self.logger.info( + "{} Request: {}?{}".format( + kwargs["method"], kwargs["url"], urlencode(kwargs["params"]) + ) + ) + self.logger.info("Query Params: {}".format(kwargs["params"])) + else: + self.logger.info("{} Request: {}".format(kwargs["method"], kwargs["url"])) + + if kwargs["headers"]: + self.logger.info("Headers:") + for key, value in kwargs["headers"].items(): + # Do not log authorization headers + if "authorization" not in key.lower(): + self.logger.info("{} : {}".format(key, value)) + + self.logger.info("-- END Twilio API Request --") + + def log_response(self, status_code: int, response: Response) -> None: + """ + Logs the HTTP response + """ + self.logger.info("Response Status Code: {}".format(status_code)) + self.logger.info("Response Headers: {}".format(response.headers)) + + +class AsyncHttpClient(HttpClient): + """ + An abstract class representing an asynchronous HTTP client. + """ + + async def request( + self, + method: str, + uri: str, + params: Optional[Dict[str, object]] = None, + data: Optional[Dict[str, object]] = None, + headers: Optional[Dict[str, str]] = None, + auth: Optional[Tuple[str, str]] = None, + timeout: Optional[float] = None, + allow_redirects: bool = False, + ) -> Response: + """ + Make an asynchronous HTTP request. + """ + raise SendgridException("AsyncHttpClient is an abstract class") + + +class SendgridHttpClient(HttpClient): + """ + General purpose HTTP Client for interacting with the Twilio API + """ + + def __init__( + self, + pool_connections: bool = True, + request_hooks: Optional[Dict[str, object]] = None, + timeout: Optional[float] = None, + logger: logging.Logger = _logger, + proxy: Optional[Dict[str, str]] = None, + max_retries: Optional[int] = None, + ): + """ + Constructor for the TwilioHttpClient + :param pool_connections + :param request_hooks + :param timeout: Timeout for the requests. + Timeout should never be zero (0) or less + :param logger + :param proxy: Http proxy for the requests session + :param max_retries: Maximum number of retries each request should attempt + """ + super().__init__(logger, False, timeout) + self.session = Session() if pool_connections else None + if self.session and max_retries is not None: + self.session.mount("https://", HTTPAdapter(max_retries=max_retries)) + if self.session is not None: + self.session.mount( + "https://", HTTPAdapter(pool_maxsize=min(32, os.cpu_count() + 4)) + ) + self.request_hooks = request_hooks or hooks.default_hooks() + self.proxy = proxy if proxy else {} + + def request( + self, + method: str, + url: str, + api_key: str = None, + params: Optional[Dict[str, object]] = None, + data: Optional[Dict[str, object]] = None, + headers: Optional[Dict[str, str]] = None, + timeout: Optional[float] = None, + allow_redirects: bool = False, + ) -> Response: + """ + Make an HTTP Request with parameters provided. + + :param api_key: + :param method: The HTTP method to use + :param url: The URL to request + :param params: Query parameters to append to the URL + :param data: Parameters to go in the body of the HTTP request + :param headers: HTTP Headers to send with the request + :param timeout: Socket/Read timeout for the request + :param allow_redirects: Whether to allow redirects + See the requests documentation for explanation of all these parameters + + :return: An HTTP response + """ + if timeout is None: + timeout = self.timeout + elif timeout <= 0: + raise ValueError(timeout) + + headers["Authorization"] = f"Bearer {api_key}" + # Currently supporting 'application/json' content type + headers["Content-Type"] = "application/json" + # auth.authenticate() + kwargs = { + "method": method.upper(), + "url": url, + "params": params, + "headers": headers, + "hooks": self.request_hooks, + } + if headers and headers.get("Content-Type") == "application/json": + kwargs["json"] = data + else: + kwargs["data"] = data + self.log_request(kwargs) + + self._test_only_last_response = None + session = self.session or Session() + request = Request(**kwargs) + self._test_only_last_request = Request(**kwargs) + + prepped_request = session.prepare_request(request) + + settings = session.merge_environment_settings( + prepped_request.url, self.proxy, None, None, None + ) + + response = session.send( + prepped_request, + allow_redirects=allow_redirects, + timeout=timeout, + **settings, + ) + print(response) + print(response.status_code) + print(response.headers) + self.log_response(response.status_code, response) + + self._test_only_last_response = Response( + int(response.status_code), response.text, response.headers + ) + + return self._test_only_last_response diff --git a/sendgrid/http/request.py b/sendgrid/http/request.py new file mode 100644 index 00000000..d157d2ef --- /dev/null +++ b/sendgrid/http/request.py @@ -0,0 +1,87 @@ +from enum import Enum +from typing import Any, Dict, Tuple, Union +from urllib.parse import urlencode + + +class Match(Enum): + ANY = "*" + + +class Request(object): + def __init__( + self, + method: Union[str, Match] = Match.ANY, + url: Union[str, Match] = Match.ANY, + auth: Union[Tuple[str, str], Match] = Match.ANY, + params: Union[Dict[str, str], Match] = Match.ANY, + data: Union[Dict[str, str], Match] = Match.ANY, + headers: Union[Dict[str, str], Match] = Match.ANY, + **kwargs: Any + ): + self.method = method + if method and method is not Match.ANY: + self.method = method.upper() + self.url = url + self.auth = auth + self.params = params + self.data = data + self.headers = headers + + @classmethod + def attribute_equal(cls, lhs, rhs) -> bool: + if lhs == Match.ANY or rhs == Match.ANY: + # ANY matches everything + return True + + lhs = lhs or None + rhs = rhs or None + + return lhs == rhs + + def __eq__(self, other) -> bool: + if not isinstance(other, Request): + return False + + return ( + self.attribute_equal(self.method, other.method) + and self.attribute_equal(self.url, other.url) + and self.attribute_equal(self.auth, other.auth) + and self.attribute_equal(self.params, other.params) + and self.attribute_equal(self.data, other.data) + and self.attribute_equal(self.headers, other.headers) + ) + + def __str__(self) -> str: + auth = "" + if self.auth and self.auth != Match.ANY: + auth = "{} ".format(self.auth) + + params = "" + if self.params and self.params != Match.ANY: + params = "?{}".format(urlencode(self.params, doseq=True)) + + data = "" + if self.data and self.data != Match.ANY: + if self.method == "GET": + data = "\n -G" + data += "\n{}".format( + "\n".join(' -d "{}={}"'.format(k, v) for k, v in self.data.items()) + ) + + headers = "" + if self.headers and self.headers != Match.ANY: + headers = "\n{}".format( + "\n".join(' -H "{}: {}"'.format(k, v) for k, v in self.headers.items()) + ) + + return "{auth}{method} {url}{params}{data}{headers}".format( + auth=auth, + method=self.method, + url=self.url, + params=params, + data=data, + headers=headers, + ) + + def __repr__(self) -> str: + return str(self) diff --git a/sendgrid/http/response.py b/sendgrid/http/response.py new file mode 100644 index 00000000..80e307d3 --- /dev/null +++ b/sendgrid/http/response.py @@ -0,0 +1,41 @@ +from typing import Any, Optional + + +class HTTPStatus: + SUCCESS = range(200, 400) # Success codes: 200-399 + CLIENT_ERROR = range(400, 500) # Client error codes: 400-499 + SERVER_ERROR = range(500, 600) # Server error codes: 500-599 + + +class Response(object): + def __init__( + self, + status_code: int, + text: str, + headers: Optional[Any] = None, + ): + self.content = text + self.headers = headers + self.cached = False + self.status_code = status_code + self.ok = self.status_code < 400 + + @property + def text(self) -> str: + return self.content + + def is_success(self): + return self.status_code in HTTPStatus.SUCCESS + + def __str__(self) -> str: + return f"Response(status_code={self.status_code}, text={self.text}, headers={self.headers}, ok={self.ok})" + + +class ApiResponse(object): + def __init__(self, status_code, model, headers): + self.status_code = status_code + self.model = model + self.headers = headers + + def __str__(self) -> str: + return f"ApiResponse(status_code={self.status_code}, model={self.model}, headers={self.headers})" diff --git a/sendgrid/utility/__init__.py b/sendgrid/utility/__init__.py new file mode 100644 index 00000000..638e153c --- /dev/null +++ b/sendgrid/utility/__init__.py @@ -0,0 +1,12 @@ +import json +from typing import Any + +from sendgrid.http.response import Response + + +def parse_response(self, response: Response) -> Any: + + if response.status_code < 200 or response.status_code >= 300: + raise self.exception(response, "Unable to create record") + + return json.loads(response.text) diff --git a/setup.py b/setup.py index 41f11e58..721a71b6 100644 --- a/setup.py +++ b/setup.py @@ -9,6 +9,7 @@ def getRequires(): deps = [ + 'requests>=2.31.0', 'python_http_client>=3.2.1', 'starkbank-ecdsa>=2.0.1' ]