diff --git a/.github/workflows/release-python-client.yml b/.github/workflows/release-python-client.yml new file mode 100644 index 00000000..559c36d2 --- /dev/null +++ b/.github/workflows/release-python-client.yml @@ -0,0 +1,28 @@ +name: Release Client - Python + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + publish-python-client: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install Poetry + uses: snok/install-poetry@v1 + - name: Install dependencies + working-directory: clients/python + run: poetry install --with=dev + - name: Build + working-directory: clients/python + run: | + poetry version $(cat ../../version.txt) + poetry publish --build -u $PYPI_USERNAME -p $PYPI_PASSWORD --dry-run + + diff --git a/.github/workflows/test-python-client.yaml b/.github/workflows/test-python-client.yaml new file mode 100644 index 00000000..654b7192 --- /dev/null +++ b/.github/workflows/test-python-client.yaml @@ -0,0 +1,61 @@ +name: Test Raccoon Python Client +on: + push: + paths: + - "clients/python/**" + branches: + - main + pull_request: + paths: + - "clients/python/**" +jobs: + format-python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install Poetry + uses: snok/install-poetry@v1 + - name: Install dependencies + working-directory: clients/python + run: poetry install --with=dev + - name: Format + working-directory: clients/python + run: poetry run python -m black . --check + lint-python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install Poetry + uses: snok/install-poetry@v1 + - name: Install dependencies + working-directory: clients/python + run: poetry install --with=dev + - name: Lint + working-directory: clients/python + run: | + poetry run python -m ruff check raccoon_client tests + poetry run python -m pylint raccoon_client tests + test-python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install Poetry + uses: snok/install-poetry@v1 + - name: Install dependencies + working-directory: clients/python + run: poetry install + - name: Unit Test + working-directory: clients/python + run: poetry run python -m unittest discover -p '*_test.py' diff --git a/.gitignore b/.gitignore index 16b5769b..584ab59e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,12 @@ coverage .vscode *.env *.idea/ -raccoon .temp +clients/python/venv +clients/python/poetry.lock +__pycache__ +raccoon +!clients/python/raccoon_client/protos/raystack/raccoon/v1beta1/ node_modules __debug.* diff --git a/buf.gen.yaml b/buf.gen.yaml index 48067c57..37d1de28 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -6,3 +6,8 @@ plugins: - plugin: buf.build/grpc/go:v1.3.0 out: proto opt: paths=source_relative,require_unimplemented_servers=true + - plugin: buf.build/protocolbuffers/python:v23.4 + out: clients/python/raccoon_client/protos + - plugin: buf.build/protocolbuffers/pyi:v23.4 + out: clients/python/raccoon_client/protos + diff --git a/clients/python/README.md b/clients/python/README.md new file mode 100644 index 00000000..c05a583c --- /dev/null +++ b/clients/python/README.md @@ -0,0 +1,41 @@ +# Python Client + +### Setup +- The project uses poetry for build, and virutal env management. +- The client was created with 3.11 as it's python environment. Hence 3.11 can be considered it's minimum requirement. It's also stated in the pyproject.toml file. +- Make sure to install poetry via https://python-poetry.org/docs/#installing-manually +- After installing poetry you can activate the env by `poetry env use` +- Install all dependencies using `poetry install --no-root --with=dev` (no-root tells that the package is not at the root of the directory) +- For setting up in IDE, make sure to setup the interpreter to use the virtual environment that was created when you activated poetry env. + +### Lint and Formatting +- We use black for formatting of python files and pylint, ruff for linting the python files. +- You can check the command for running lint and formating by referring to `test-python-client.yml` workflow. + +### Usage +- You can use the raccoon by installing it from PyPi by the following command + - From Pypi + ```pip install raccoon_client``` + - From Github + ```pip install raccoon_client@git+https://github.com/raystack/raccoon@$VERSION#subdirectory=clients/python``` + where $VERSION is a git tag. +- An example on how to use the client is under the [examples](examples) package. + +### Confiugration +The client supports the following configuration: + +| Name | Description | Type | Default | +|---------|-----------------------------------------------------------------------------------|-----------------------------------|---------| +| url | The remote server url to connect to | string | "" | +| retries | The max number of retries to be attempted before an event is considered a failure | int (<10) | 3 | +| timeout | The number of seconds to wait before timing out the request | float | 1.0 | +| serialiser | The format to which event field of client.Event serialises it's data to | Serialiser Enum(JSON or PROTOBUF) | JSON | +|wire_type | The format in which the request payload should be sent to server | Wire Type Enum(JSON or PROTOBUF) | JSON | +| headers | HTTP header key value pair to be sent along with each request | dict | {} | + + +Note: +- During development, make sure to open just the python directory, otherwise the IDE misconfigures the imports. +- The protos package contain generated code and should not be edited manually. +- It's recommended not to use JSON serialiser, when using proto generated classes as your events due to JSON encoding incompatibility. [Issue](https://github.com/raystack/raccoon/issues/67) + diff --git a/clients/python/dist/raccoon_client-0.2.1-py3-none-any.whl b/clients/python/dist/raccoon_client-0.2.1-py3-none-any.whl new file mode 100644 index 00000000..b7bfcf35 Binary files /dev/null and b/clients/python/dist/raccoon_client-0.2.1-py3-none-any.whl differ diff --git a/clients/python/examples/__init__.py b/clients/python/examples/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/clients/python/examples/rest.py b/clients/python/examples/rest.py new file mode 100644 index 00000000..fdb3155a --- /dev/null +++ b/clients/python/examples/rest.py @@ -0,0 +1,73 @@ +from raccoon_client.client import Event +from raccoon_client.protos.raystack.raccoon.v1beta1.raccoon_pb2 import SendEventRequest +from raccoon_client.rest.client import RestClient +from raccoon_client.rest.option import RestClientConfigBuilder +from raccoon_client.serde.enum import Serialiser, WireType + + +def example_json_serialiser_json_wire(): + event_data = {"a": "field a", "b": "field b"} + + config = ( + RestClientConfigBuilder() + .with_url("http://localhost:8080/api/v1/events") + .with_serialiser(Serialiser.JSON) + .with_wire_type(WireType.JSON) + .build() + ) # other parameters supported by the config builder can be checked in its method definition. + rest_client = RestClient(config) + topic_to_publish_to = "test_topic_2" + e = Event(topic_to_publish_to, event_data) + req_id, response, raw = rest_client.send([e]) + return req_id, response, raw + + +def example_protobuf_serialiser_protobuf_wire(): + event_data = ( + SendEventRequest() + ) # sample generated proto class which is an event to send to raccoon + event_data.sent_time = 1000 + event_data.req_guid = "some string" + + config = ( + RestClientConfigBuilder() + .with_url("http://localhost:8080/api/v1/events") + .with_serialiser(Serialiser.PROTOBUF) + .with_wire_type(WireType.PROTOBUF) + .with_timeout(10.0) + .with_retry_count(3) + .with_headers({"Authorization": "TOKEN"}) + .build() + ) # other parameters supported by the config builder can be checked in its method definition. + rest_client = RestClient(config) + topic_to_publish_to = "test_topic_2" + e = Event(topic_to_publish_to, event_data) + req_id, response, raw = rest_client.send([e]) + return req_id, response, raw + + +def example_protobuf_serialiser_json_wire(): + event_data = ( + SendEventRequest() + ) # sample generated proto class which is an event to send to raccoon + event_data.sent_time = 1000 + event_data.req_guid = "some string" + + config = ( + RestClientConfigBuilder() + .with_url("http://localhost:8080/api/v1/events") + .with_serialiser(Serialiser.PROTOBUF) + .with_wire_type(WireType.JSON) + .build() + ) # other parameters supported by the config builder can be checked in its method definition. + rest_client = RestClient(config) + topic_to_publish_to = "test_topic_2" + e = Event(topic_to_publish_to, event_data) + req_id, response, raw = rest_client.send([e]) + return req_id, response, raw + + +if __name__ == "__main__": + example_json_serialiser_json_wire() + example_protobuf_serialiser_protobuf_wire() + example_protobuf_serialiser_json_wire() diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml new file mode 100644 index 00000000..51fc0bae --- /dev/null +++ b/clients/python/pyproject.toml @@ -0,0 +1,44 @@ +[tool.poetry] +name = "raccoon-client" +version = "v0.2.1" +description = "A python client to serve requests to raccoon server" +authors = ["Punit Kulal "] +readme = "README.md" +packages = [{include = "raccoon_client"}] + +[tool.poetry.dependencies] +python = "^3.9" +requests = "^2.31.0" +protobuf = "^4.23.4" +google = "^3.0.0" + +[tool.poetry.group.dev.dependencies] +requests = "^2.31.0" +black = "^23.7.0" +pylint = "^2.17.5" +ruff = "^0.0.285" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 88 +extend-exclude = '.*_pb2.py|.*_pb2.pyi' + +[tool.ruff] +ignore = ["E501"] + +[tool.pylint.'MESSAGES CONTROL'] +disable = [ + 'no-name-in-module', + 'line-too-long', + 'missing-module-docstring', + 'bad-indentation', + 'missing-class-docstring', + 'missing-function-docstring', + 'protected-access' +] + +[tool.pylint.MASTER] +ignore-patterns = '.*_pb2.py|.*_pb2.pyi' diff --git a/clients/python/raccoon_client/__init__.py b/clients/python/raccoon_client/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/clients/python/raccoon_client/client.py b/clients/python/raccoon_client/client.py new file mode 100644 index 00000000..1b185503 --- /dev/null +++ b/clients/python/raccoon_client/client.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass + +from raccoon_client.protos.raystack.raccoon.v1beta1.raccoon_pb2 import ( + SendEventResponse, +) + + +@dataclass +class RaccoonResponseError(IOError): + def __init__(self, status_code, msg): + super().__init__(msg) + self.status_code = status_code + + +@dataclass +class Event: + type: str + event: object + + +class Client: # pylint: disable=too-few-public-methods + def send(self, events: [Event]) -> (str, SendEventResponse, RaccoonResponseError): + raise NotImplementedError() diff --git a/clients/python/raccoon_client/protos/__init__.py b/clients/python/raccoon_client/protos/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/clients/python/raccoon_client/protos/raystack/__init__.py b/clients/python/raccoon_client/protos/raystack/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/clients/python/raccoon_client/protos/raystack/raccoon/__init__.py b/clients/python/raccoon_client/protos/raystack/raccoon/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/clients/python/raccoon_client/protos/raystack/raccoon/v1beta1/__init__.py b/clients/python/raccoon_client/protos/raystack/raccoon/v1beta1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/clients/python/raccoon_client/protos/raystack/raccoon/v1beta1/raccoon_pb2.py b/clients/python/raccoon_client/protos/raystack/raccoon/v1beta1/raccoon_pb2.py new file mode 100644 index 00000000..b0ee5a6e --- /dev/null +++ b/clients/python/raccoon_client/protos/raystack/raccoon/v1beta1/raccoon_pb2.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: raystack/raccoon/v1beta1/raccoon.proto +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n&raystack/raccoon/v1beta1/raccoon.proto\x12\x18raystack.raccoon.v1beta1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x9f\x01\n\x10SendEventRequest\x12\x19\n\x08req_guid\x18\x01 \x01(\tR\x07reqGuid\x12\x37\n\tsent_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\x08sentTime\x12\x37\n\x06\x65vents\x18\x03 \x03(\x0b\x32\x1f.raystack.raccoon.v1beta1.EventR\x06\x65vents\"<\n\x05\x45vent\x12\x1f\n\x0b\x65vent_bytes\x18\x01 \x01(\x0cR\neventBytes\x12\x12\n\x04type\x18\x02 \x01(\tR\x04type\"\xba\x02\n\x11SendEventResponse\x12\x38\n\x06status\x18\x01 \x01(\x0e\x32 .raystack.raccoon.v1beta1.StatusR\x06status\x12\x32\n\x04\x63ode\x18\x02 \x01(\x0e\x32\x1e.raystack.raccoon.v1beta1.CodeR\x04\x63ode\x12\x1b\n\tsent_time\x18\x03 \x01(\x03R\x08sentTime\x12\x16\n\x06reason\x18\x04 \x01(\tR\x06reason\x12I\n\x04\x64\x61ta\x18\x05 \x03(\x0b\x32\x35.raystack.raccoon.v1beta1.SendEventResponse.DataEntryR\x04\x64\x61ta\x1a\x37\n\tDataEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01*F\n\x06Status\x12\x16\n\x12STATUS_UNSPECIFIED\x10\x00\x12\x12\n\x0eSTATUS_SUCCESS\x10\x01\x12\x10\n\x0cSTATUS_ERROR\x10\x02*\xa0\x01\n\x04\x43ode\x12\x14\n\x10\x43ODE_UNSPECIFIED\x10\x00\x12\x0b\n\x07\x43ODE_OK\x10\x01\x12\x14\n\x10\x43ODE_BAD_REQUEST\x10\x02\x12\x17\n\x13\x43ODE_INTERNAL_ERROR\x10\x03\x12%\n!CODE_MAX_CONNECTION_LIMIT_REACHED\x10\x04\x12\x1f\n\x1b\x43ODE_MAX_USER_LIMIT_REACHED\x10\x05\x32t\n\x0c\x45ventService\x12\x64\n\tSendEvent\x12*.raystack.raccoon.v1beta1.SendEventRequest\x1a+.raystack.raccoon.v1beta1.SendEventResponseB[\n\x1aio.raystack.proton.raccoonB\nEventProtoP\x01Z/github.com/raystack/proton/raccoon/v1;raccoonv1b\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'raystack.raccoon.v1beta1.raccoon_pb2', _globals) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'\n\032io.raystack.proton.raccoonB\nEventProtoP\001Z/github.com/raystack/proton/raccoon/v1;raccoonv1' + _SENDEVENTRESPONSE_DATAENTRY._options = None + _SENDEVENTRESPONSE_DATAENTRY._serialized_options = b'8\001' + _globals['_STATUS']._serialized_start=642 + _globals['_STATUS']._serialized_end=712 + _globals['_CODE']._serialized_start=715 + _globals['_CODE']._serialized_end=875 + _globals['_SENDEVENTREQUEST']._serialized_start=102 + _globals['_SENDEVENTREQUEST']._serialized_end=261 + _globals['_EVENT']._serialized_start=263 + _globals['_EVENT']._serialized_end=323 + _globals['_SENDEVENTRESPONSE']._serialized_start=326 + _globals['_SENDEVENTRESPONSE']._serialized_end=640 + _globals['_SENDEVENTRESPONSE_DATAENTRY']._serialized_start=585 + _globals['_SENDEVENTRESPONSE_DATAENTRY']._serialized_end=640 + _globals['_EVENTSERVICE']._serialized_start=877 + _globals['_EVENTSERVICE']._serialized_end=993 +# @@protoc_insertion_point(module_scope) diff --git a/clients/python/raccoon_client/protos/raystack/raccoon/v1beta1/raccoon_pb2.pyi b/clients/python/raccoon_client/protos/raystack/raccoon/v1beta1/raccoon_pb2.pyi new file mode 100644 index 00000000..1e204d29 --- /dev/null +++ b/clients/python/raccoon_client/protos/raystack/raccoon/v1beta1/raccoon_pb2.pyi @@ -0,0 +1,73 @@ +### Generated by protoc + +from google.protobuf import timestamp_pb2 as _timestamp_pb2 +from google.protobuf.internal import containers as _containers +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class Status(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = [] + STATUS_UNSPECIFIED: _ClassVar[Status] + STATUS_SUCCESS: _ClassVar[Status] + STATUS_ERROR: _ClassVar[Status] + +class Code(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = [] + CODE_UNSPECIFIED: _ClassVar[Code] + CODE_OK: _ClassVar[Code] + CODE_BAD_REQUEST: _ClassVar[Code] + CODE_INTERNAL_ERROR: _ClassVar[Code] + CODE_MAX_CONNECTION_LIMIT_REACHED: _ClassVar[Code] + CODE_MAX_USER_LIMIT_REACHED: _ClassVar[Code] +STATUS_UNSPECIFIED: Status +STATUS_SUCCESS: Status +STATUS_ERROR: Status +CODE_UNSPECIFIED: Code +CODE_OK: Code +CODE_BAD_REQUEST: Code +CODE_INTERNAL_ERROR: Code +CODE_MAX_CONNECTION_LIMIT_REACHED: Code +CODE_MAX_USER_LIMIT_REACHED: Code + +class SendEventRequest(_message.Message): + __slots__ = ["req_guid", "sent_time", "events"] + REQ_GUID_FIELD_NUMBER: _ClassVar[int] + SENT_TIME_FIELD_NUMBER: _ClassVar[int] + EVENTS_FIELD_NUMBER: _ClassVar[int] + req_guid: str + sent_time: _timestamp_pb2.Timestamp + events: _containers.RepeatedCompositeFieldContainer[Event] + def __init__(self, req_guid: _Optional[str] = ..., sent_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., events: _Optional[_Iterable[_Union[Event, _Mapping]]] = ...) -> None: ... + +class Event(_message.Message): + __slots__ = ["event_bytes", "type"] + EVENT_BYTES_FIELD_NUMBER: _ClassVar[int] + TYPE_FIELD_NUMBER: _ClassVar[int] + event_bytes: bytes + type: str + def __init__(self, event_bytes: _Optional[bytes] = ..., type: _Optional[str] = ...) -> None: ... + +class SendEventResponse(_message.Message): + __slots__ = ["status", "code", "sent_time", "reason", "data"] + class DataEntry(_message.Message): + __slots__ = ["key", "value"] + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + STATUS_FIELD_NUMBER: _ClassVar[int] + CODE_FIELD_NUMBER: _ClassVar[int] + SENT_TIME_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + DATA_FIELD_NUMBER: _ClassVar[int] + status: Status + code: Code + sent_time: int + reason: str + data: _containers.ScalarMap[str, str] + def __init__(self, status: _Optional[_Union[Status, str]] = ..., code: _Optional[_Union[Code, str]] = ..., sent_time: _Optional[int] = ..., reason: _Optional[str] = ..., data: _Optional[_Mapping[str, str]] = ...) -> None: ... diff --git a/clients/python/raccoon_client/rest/__init__.py b/clients/python/raccoon_client/rest/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/clients/python/raccoon_client/rest/client.py b/clients/python/raccoon_client/rest/client.py new file mode 100644 index 00000000..d398d438 --- /dev/null +++ b/clients/python/raccoon_client/rest/client.py @@ -0,0 +1,85 @@ +import time +import uuid + +import requests +from requests.adapters import HTTPAdapter +from urllib3 import Retry + +from raccoon_client.client import Client, Event, RaccoonResponseError +from raccoon_client.protos.raystack.raccoon.v1beta1.raccoon_pb2 import ( + SendEventRequest, + SendEventResponse, + Event as EventPb, +) +from raccoon_client.rest.option import RestClientConfig, HttpConfig +from raccoon_client.serde.serde import Serde +from raccoon_client.serde.util import get_serde, CONTENT_TYPE_HEADER_KEY, get_wire_type +from raccoon_client.serde.wire import Wire + + +class RestClient(Client): # pylint: disable=too-few-public-methods + session: requests.Session + serde: Serde + wire: Wire + http_config: HttpConfig + + def __init__(self, config: RestClientConfig): + self.config = config + self.session = requests.session() + self.http_config = self.config.get_http_config() + self.serde = get_serde(config.serialiser) + self.wire = get_wire_type(config.wire_type) + self.http_config.headers = self._set_content_type_header(config.http.headers) + self._set_retries(self.session, config.http.max_retries) + + def _set_retries(self, session, max_retries): + retries = Retry( + total=max_retries, + backoff_factor=1, + status_forcelist=[500, 502, 503, 504, 521, 429], + allowed_methods=["POST"], + raise_on_status=False, + ) + session.mount("https://", HTTPAdapter(max_retries=retries)) + session.mount("http://", HTTPAdapter(max_retries=retries)) + + def send(self, events: [Event]): + req = self._get_init_request() + events_pb = [self._convert_to_event_pb(event) for event in events] + req.events.extend(events_pb) + response = self.session.post( + url=self.http_config.url, + data=self.wire.marshal(req), + headers=self.http_config.headers, + timeout=self.http_config.timeout, + ) + deserialised_response, err = self._parse_response(response) + return req.req_guid, deserialised_response, err + + def _convert_to_event_pb(self, event: Event): + proto_event = EventPb() + proto_event.event_bytes = self.serde.serialise(event.event) + proto_event.type = event.type + return proto_event + + def _get_init_request(self): + req = SendEventRequest() + req.req_guid = str(uuid.uuid4()) + req.sent_time.FromNanoseconds(time.time_ns()) + return req + + def _set_content_type_header(self, headers: dict): + headers[CONTENT_TYPE_HEADER_KEY] = self.wire.get_content_type() + return headers + + def _parse_response( + self, response: requests.Response + ) -> (SendEventResponse, ValueError): + event_response = error = None + if len(response.content) != 0: + event_response = self.wire.unmarshal(response.content, SendEventResponse()) + + if 200 < response.status_code >= 300: + error = RaccoonResponseError(response.status_code, response.content) + + return event_response, error diff --git a/clients/python/raccoon_client/rest/option.py b/clients/python/raccoon_client/rest/option.py new file mode 100644 index 00000000..ce3f6bac --- /dev/null +++ b/clients/python/raccoon_client/rest/option.py @@ -0,0 +1,86 @@ +from dataclasses import dataclass + +from raccoon_client.serde.enum import Serialiser, WireType + + +@dataclass +class HttpConfig: + url: str + max_retries: int + timeout: float + headers: dict[str] + + def __init__(self): + self.url = "" + self.max_retries = 3 + self.timeout = 1.0 + self.headers = {} + + def clone(self): + cloned_config = HttpConfig() + cloned_config.url = self.url + cloned_config.max_retries = self.max_retries + cloned_config.timeout = self.timeout + cloned_config.headers = self.headers + return cloned_config + + +@dataclass +class RestClientConfig: + serialiser: Serialiser + wire_type: WireType + http: HttpConfig + + def __init__(self): + self.serialiser = Serialiser.JSON + self.wire_type = WireType.JSON + self.http = HttpConfig() + + def get_http_config(self): + return self.http.clone() + + +class RestClientConfigBuilder: + def __init__(self): + self.config = RestClientConfig() + + def with_url(self, url: str): + self.config.http.url = url + return self + + def with_retry_count(self, retry_count: int): + if not isinstance(retry_count, int): + raise ValueError("retry_count should be an integer") + if retry_count > 10: + raise ValueError("retry should not be greater than 10") + self.config.http.max_retries = retry_count + return self + + def with_serialiser(self, content_type: Serialiser): + if not isinstance(content_type, Serialiser): + raise ValueError("invalid serialiser/deserialiser type") + self.config.serialiser = content_type + return self + + def with_headers(self, headers: dict): + self.config.http.headers = headers + return self + + def with_wire_type(self, wire_type: WireType): + if not isinstance(wire_type, WireType): + raise ValueError("invalid serialiser/deserialiser type") + self.config.wire_type = wire_type + return self + + def with_timeout(self, timeout: float): + if not isinstance(timeout, float): + raise ValueError + if timeout > 10: + raise ValueError("timeout too high") + if timeout < 0.010: + raise ValueError("timeout is too low") + self.config.http.timeout = timeout + return self + + def build(self): + return self.config diff --git a/clients/python/raccoon_client/serde/__init__.py b/clients/python/raccoon_client/serde/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/clients/python/raccoon_client/serde/enum.py b/clients/python/raccoon_client/serde/enum.py new file mode 100644 index 00000000..94a6302c --- /dev/null +++ b/clients/python/raccoon_client/serde/enum.py @@ -0,0 +1,14 @@ +from enum import Enum + +from raccoon_client.serde.json_serde import JsonSerde +from raccoon_client.serde.protobuf_serde import ProtobufSerde + + +class Serialiser(Enum): + JSON = JsonSerde + PROTOBUF = ProtobufSerde + + +class WireType(Enum): + JSON = JsonSerde + PROTOBUF = ProtobufSerde diff --git a/clients/python/raccoon_client/serde/json_serde.py b/clients/python/raccoon_client/serde/json_serde.py new file mode 100644 index 00000000..a039b625 --- /dev/null +++ b/clients/python/raccoon_client/serde/json_serde.py @@ -0,0 +1,40 @@ +import json + +from google.protobuf import json_format +from google.protobuf.message import Message + +from raccoon_client.protos.raystack.raccoon.v1beta1.raccoon_pb2 import ( + SendEventRequest, + SendEventResponse, +) +from raccoon_client.serde.serde import Serde +from raccoon_client.serde.wire import Wire + + +class JsonSerde(Serde, Wire): + # uses json.dumps since the input can be either protobuf message or dictionary + def serialise(self, event): + if isinstance(event, Message): + return bytes(json_format.MessageToJson(event), "utf-8") + return bytes(json.dumps(event), "utf-8") + + def get_content_type(self): + return "application/json" + + def marshal(self, event: SendEventRequest): + """ + The sent_time is added in the dict format because protobuf style guide/conventions convert Timestamp Type to ISO format. + Since, the server doesn't support this format, we are explicitly setting the sent_time in the format the servers + supports. Ref: https://github.com/raystack/raccoon/issues/67 + """ + req_dict = json_format.MessageToDict(event, preserving_proto_field_name=True) + req_dict["sent_time"] = { + "seconds": event.sent_time.seconds, + "nanos": event.sent_time.nanos, + } + return json.dumps( + req_dict + ) # uses json_format since the event is always a protobuf message + + def unmarshal(self, data, template: SendEventResponse): + return json_format.Parse(data, template) diff --git a/clients/python/raccoon_client/serde/protobuf_serde.py b/clients/python/raccoon_client/serde/protobuf_serde.py new file mode 100644 index 00000000..886a12b4 --- /dev/null +++ b/clients/python/raccoon_client/serde/protobuf_serde.py @@ -0,0 +1,21 @@ +from google.protobuf.message import Message + +from raccoon_client.serde.serde import Serde +from raccoon_client.serde.wire import Wire + + +class ProtobufSerde(Serde, Wire): + def serialise(self, event: Message): + if not isinstance(event, Message): + raise ValueError("event should be a protobuf message") + return event.SerializeToString() # the name is a misnomer, returns bytes + + def marshal(self, event: Message): + return event.SerializeToString() + + def unmarshal(self, data: bytes, template: Message): + template.ParseFromString(data) + return template + + def get_content_type(self): + return "application/proto" diff --git a/clients/python/raccoon_client/serde/serde.py b/clients/python/raccoon_client/serde/serde.py new file mode 100644 index 00000000..2a72ae68 --- /dev/null +++ b/clients/python/raccoon_client/serde/serde.py @@ -0,0 +1,3 @@ +class Serde: # pylint: disable=too-few-public-methods + def serialise(self, event): + raise NotImplementedError() diff --git a/clients/python/raccoon_client/serde/util.py b/clients/python/raccoon_client/serde/util.py new file mode 100644 index 00000000..32255b20 --- /dev/null +++ b/clients/python/raccoon_client/serde/util.py @@ -0,0 +1,24 @@ +from raccoon_client.serde.enum import Serialiser, WireType +from raccoon_client.serde.json_serde import JsonSerde +from raccoon_client.serde.protobuf_serde import ProtobufSerde +from raccoon_client.serde.serde import Serde +from raccoon_client.serde.wire import Wire + + +def get_serde(serialiser) -> Serde: + if serialiser == Serialiser.JSON: + return JsonSerde() + if serialiser == Serialiser.PROTOBUF: + return ProtobufSerde() + raise ValueError() + + +def get_wire_type(wire_type) -> Wire: + if wire_type == WireType.JSON: + return JsonSerde() + if wire_type == WireType.PROTOBUF: + return ProtobufSerde() + raise ValueError() + + +CONTENT_TYPE_HEADER_KEY = "Content-Type" diff --git a/clients/python/raccoon_client/serde/wire.py b/clients/python/raccoon_client/serde/wire.py new file mode 100644 index 00000000..f149c055 --- /dev/null +++ b/clients/python/raccoon_client/serde/wire.py @@ -0,0 +1,9 @@ +class Wire: + def marshal(self, event): + raise NotImplementedError("not implemented") + + def unmarshal(self, data, template): + raise NotImplementedError("not implemented") + + def get_content_type(self): + raise NotImplementedError("not implemented") diff --git a/clients/python/tests/__init__.py b/clients/python/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/clients/python/tests/unit/__init__.py b/clients/python/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/clients/python/tests/unit/rest/__init__.py b/clients/python/tests/unit/rest/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/clients/python/tests/unit/rest/client_test.py b/clients/python/tests/unit/rest/client_test.py new file mode 100644 index 00000000..d7ab04d4 --- /dev/null +++ b/clients/python/tests/unit/rest/client_test.py @@ -0,0 +1,425 @@ +import json +import time +import unittest + +from unittest import mock +from unittest.mock import patch + +import requests +from google.protobuf import timestamp_pb2 + +from raccoon_client import client +from raccoon_client.protos.raystack.raccoon.v1beta1.raccoon_pb2 import ( + SendEventRequest, + Status, + Code, + SendEventResponse, +) +from raccoon_client.rest.client import RestClient +from raccoon_client.rest.option import RestClientConfigBuilder +from raccoon_client.serde.enum import Serialiser, WireType +from raccoon_client.serde.json_serde import JsonSerde +from raccoon_client.serde.protobuf_serde import ProtobufSerde + + +def get_marshalled_response(): + return b"\x08\x01\x10\x01\x18\x90\xe8\xc9\x97\xa8\xa2\x85\xbe\x17*0\n\x08req_guid\x12$17e2ac19-df8b-4a30-b111-fd7f5073d2f5" + + +def get_marshalled_request(): + return b"\n$17e2ac19-df8b-4a30-b111-fd7f5073d2f5\x12\x0b\x08\xe9\xe4\xf6\xa6\x06\x10\x90\xb4\x86p\x1a$\n\x14data bytes for click\x12\x0cclick-events" + + +def get_static_uuid(): + return "17e2ac19-df8b-4a30-b111-fd7f5073d2f5" + + +def get_static_time_ns(): + return 1692250729234986000 + + +def get_static_time(): + return 1692276392 + + +def get_stub_event_payload_json(): + return client.Event("random_topic", {"a": "abc"}) + + +def get_stub_response_json(): + response = requests.Response() + response.status_code = requests.status_codes.codes["ok"] + json_response = { + "status": 1, + "code": 1, + "sent_time": get_static_time(), + "data": {"req_guid": get_static_uuid()}, + } + json_string2 = json.dumps(json_response) + response._content = json_string2 + return response + + +def get_stub_response_non_ok_json(): + response = requests.Response() + response.status_code = requests.status_codes.codes["not_found"] + json_response = { + "status": Status.STATUS_ERROR, + "code": Code.CODE_BAD_REQUEST, + "sent_time": get_static_time(), + "data": {"req_guid": get_static_uuid()}, + } + json_string2 = json.dumps(json_response) + response._content = json_string2 + return response + + +def get_stub_response_protobuf(): + response = requests.Response() + response.status_code = requests.status_codes.codes["ok"] + response._content = get_marshalled_response() + return response + + +def get_stub_event_payload_protobuf(): + return client.Event( + "random_topic", + ProtobufSerde().unmarshal( + get_marshalled_request(), SendEventRequest() + ), # sample proto serialised to bytes) + ) + + +class RestClientTest(unittest.TestCase): + sample_url = "http://localhost:8080/api/v1/" + max_retries = 3 + serialiser = Serialiser.JSON + wire_type = WireType.JSON + headers = {"X-Sample": "working"} + + def test_client_creation_success(self): + client_config = ( + RestClientConfigBuilder() + .with_url(self.sample_url) + .with_serialiser(self.serialiser) + .with_retry_count(self.max_retries) + .with_wire_type(self.wire_type) + .with_timeout(2.0) + .with_headers(self.headers) + .build() + ) + rest_client = RestClient(client_config) + self.assertEqual( + rest_client.http_config.url, self.sample_url, "sample_urls do not match" + ) + self.assertEqual( + rest_client.session.adapters["https://"].max_retries.total, + self.max_retries, + ) + self.assertEqual( + rest_client.session.adapters["http://"].max_retries.total, self.max_retries + ) + self.assertEqual( + type(rest_client.serde), + self.serialiser.value, + "serialiser is configured incorrectly", + ) + self.assertEqual( + type(rest_client.wire), + self.wire_type.value, + "wire type is configured incorrectly", + ) + self.assertEqual( + rest_client.http_config.timeout, 2.0, "timeout is configured incorrectly" + ) + self.assertEqual( + rest_client.http_config.headers, + {"Content-Type": "application/json", "X-Sample": "working"}, + ) + + def test_client_creation_success_with_protobuf(self): + client_config = ( + RestClientConfigBuilder() + .with_url(self.sample_url) + .with_serialiser(Serialiser.PROTOBUF) + .with_retry_count(self.max_retries) + .with_wire_type(WireType.PROTOBUF) + .with_timeout(2.0) + .with_headers({}) + .build() + ) + rest_client = RestClient(client_config) + self.assertEqual( + rest_client.http_config.url, self.sample_url, "sample_urls do not match" + ) + self.assertEqual( + rest_client.session.adapters["https://"].max_retries.total, + self.max_retries, + ) + self.assertEqual( + rest_client.session.adapters["http://"].max_retries.total, self.max_retries + ) + self.assertEqual( + type(rest_client.serde), + Serialiser.PROTOBUF.value, + "serialiser is configured incorrectly", + ) + self.assertEqual( + type(rest_client.wire), + WireType.PROTOBUF.value, + "wire type is configured incorrectly", + ) + self.assertEqual( + rest_client.http_config.timeout, 2.0, "timeout is configured incorrectly" + ) + self.assertEqual( + rest_client.http_config.headers, {"Content-Type": "application/proto"} + ) + + def test_client_creation_failure(self): + builder = RestClientConfigBuilder().with_url(self.sample_url) + self.assertRaises(ValueError, builder.with_serialiser, "JSON") + self.assertRaises(ValueError, builder.with_wire_type, "PROTOBUF") + self.assertRaises(ValueError, builder.with_retry_count, "five") + self.assertRaises(ValueError, builder.with_timeout, 0.005) + + @patch("raccoon_client.rest.client.time.time_ns") + def test_get_stub_request(self, time_ns): + time_ns.return_value = get_static_time_ns() + rest_client = self._get_rest_client() + time_stamp = timestamp_pb2.Timestamp() # pylint: disable=no-member + time_stamp.FromNanoseconds(time.time_ns()) + with patch( + "raccoon_client.rest.client.uuid.uuid4", return_value=get_static_uuid() + ): + req = rest_client._get_init_request() + self.assertEqual(req.req_guid, get_static_uuid()) + self.assertEqual(req.sent_time.seconds, time_stamp.seconds) + self.assertEqual(req.sent_time.nanos, time_stamp.nanos) + + def test_uniqueness_of_stub_request(self): + rest_client = self._get_rest_client() + req1 = rest_client._get_init_request() + time.sleep(1) + req2 = rest_client._get_init_request() + self.assertNotEqual(req1.req_guid, req2.req_guid) + self.assertNotEqual(req1.sent_time.nanos, req2.sent_time.nanos) + self.assertNotEqual(req1.sent_time.seconds, req2.sent_time.seconds) + + def test_client_send_success_json(self): + session_mock = mock.Mock() + post = mock.MagicMock() + session_mock.post = post + post.return_value = get_stub_response_json() + event_arr = [get_stub_event_payload_json()] + req = SendEventRequest() + expected_req = SendEventRequest() + expected_req.req_guid = get_static_uuid() + req.req_guid = get_static_uuid() + time_in_ns = time.time_ns() + req.sent_time.FromNanoseconds(time_in_ns) + expected_req.sent_time.FromNanoseconds(time_in_ns) + with patch( + "raccoon_client.rest.client.requests.session", return_value=session_mock + ): + rest_client = self._get_rest_client() + expected_req.events.append( + rest_client._convert_to_event_pb(get_stub_event_payload_json()) + ) + serialised_data = JsonSerde().marshal(expected_req) + rest_client._get_init_request = mock.MagicMock() + rest_client._get_init_request.return_value = req + rest_client._parse_response = mock.MagicMock() + rest_client._parse_response.return_value = [SendEventResponse(), None] + rest_client.send(event_arr) + post.assert_called_once_with( + url=self.sample_url, + data=serialised_data, + headers={"Content-Type": "application/json"}, + timeout=2.0, + ) + rest_client._parse_response.assert_called_once_with(post.return_value) + + def test_client_send_success_protobuf(self): + session_mock = mock.Mock() + post = mock.MagicMock() + session_mock.post = post + post.return_value = get_stub_response_protobuf() + event_arr = [get_stub_event_payload_protobuf()] + req = SendEventRequest() + expected_req = SendEventRequest() + expected_req.req_guid = get_static_uuid() + req.req_guid = get_static_uuid() + time_in_ns = time.time_ns() + req.sent_time.FromNanoseconds(time_in_ns) + expected_req.sent_time.FromNanoseconds(time_in_ns) + with patch( + "raccoon_client.rest.client.requests.session", return_value=session_mock + ): + rest_client = self._get_rest_client( + serialiser=Serialiser.PROTOBUF, wire_type=WireType.PROTOBUF + ) + expected_req.events.append( + rest_client._convert_to_event_pb(get_stub_event_payload_protobuf()) + ) + serialised_data = expected_req.SerializeToString() + rest_client._get_init_request = mock.MagicMock() + rest_client._get_init_request.return_value = req + rest_client._parse_response = mock.MagicMock() + rest_client._parse_response.return_value = [SendEventResponse(), None] + rest_client.send(event_arr) + post.assert_called_once_with( + url=self.sample_url, + data=serialised_data, + headers={"Content-Type": "application/proto"}, + timeout=2.0, + ) + rest_client._parse_response.assert_called_once_with(post.return_value) + + def test_client_send_success_json_serialiser_protobuf_wire(self): + session_mock = mock.Mock() + post = mock.MagicMock() + session_mock.post = post + post.return_value = get_stub_response_protobuf() + event_arr = [get_stub_event_payload_json()] + req = SendEventRequest() + expected_req = SendEventRequest() + expected_req.req_guid = get_static_uuid() + req.req_guid = get_static_uuid() + time_in_ns = time.time_ns() + req.sent_time.FromNanoseconds(time_in_ns) + expected_req.sent_time.FromNanoseconds(time_in_ns) + with patch( + "raccoon_client.rest.client.requests.session", return_value=session_mock + ): + rest_client = self._get_rest_client( + serialiser=Serialiser.JSON, wire_type=WireType.PROTOBUF + ) + expected_req.events.append( + rest_client._convert_to_event_pb(get_stub_event_payload_json()) + ) + serialised_data = expected_req.SerializeToString() + rest_client._get_init_request = mock.MagicMock() + rest_client._get_init_request.return_value = req + rest_client._parse_response = mock.MagicMock() + rest_client._parse_response.return_value = [SendEventResponse(), None] + rest_client.send(event_arr) + post.assert_called_once_with( + url=self.sample_url, + data=serialised_data, + headers={"Content-Type": "application/proto"}, + timeout=2.0, + ) + rest_client._parse_response.assert_called_once_with(post.return_value) + + def test_client_send_success_protobuf_serialiser_json_wire(self): + session_mock = mock.Mock() + post = mock.MagicMock() + session_mock.post = post + post.return_value = get_stub_response_json() + event_arr = [get_stub_event_payload_protobuf()] + req = SendEventRequest() + expected_req = SendEventRequest() + expected_req.req_guid = get_static_uuid() + req.req_guid = get_static_uuid() + time_in_ns = time.time_ns() + req.sent_time.FromNanoseconds(time_in_ns) + expected_req.sent_time.FromNanoseconds(time_in_ns) + with patch( + "raccoon_client.rest.client.requests.session", return_value=session_mock + ): + rest_client = self._get_rest_client( + serialiser=Serialiser.PROTOBUF, wire_type=WireType.JSON + ) + expected_req.events.append( + rest_client._convert_to_event_pb(get_stub_event_payload_protobuf()) + ) + serialised_data = JsonSerde().marshal(expected_req) + rest_client._get_init_request = mock.MagicMock() + rest_client._get_init_request.return_value = req + rest_client._parse_response = mock.MagicMock() + rest_client._parse_response.return_value = [SendEventResponse(), None] + rest_client.send(event_arr) + post.assert_called_once_with( + url=self.sample_url, + data=serialised_data, + headers={"Content-Type": "application/json"}, + timeout=2.0, + ) + rest_client._parse_response.assert_called_once_with(post.return_value) + + def test_client_send_connection_failure(self): + session_mock = mock.Mock() + post = mock.MagicMock() + session_mock.post = post + post.side_effect = ConnectionError("error connecting to host") + event_arr = [get_stub_event_payload_json()] + req = SendEventRequest() + expected_req = SendEventRequest() + expected_req.req_guid = get_static_uuid() + req.req_guid = get_static_uuid() + time_in_ns = time.time_ns() + req.sent_time.FromNanoseconds(time_in_ns) + expected_req.sent_time.FromNanoseconds(time_in_ns) + with patch( + "raccoon_client.rest.client.requests.session", return_value=session_mock + ): + rest_client = self._get_rest_client() + expected_req.events.append( + rest_client._convert_to_event_pb(get_stub_event_payload_json()) + ) + serialised_data = JsonSerde().marshal(expected_req) + rest_client._get_init_request = mock.MagicMock() + rest_client._get_init_request.return_value = req + rest_client._parse_response = mock.MagicMock() + self.assertRaises(ConnectionError, rest_client.send, event_arr) + post.assert_called_once_with( + url=self.sample_url, + data=serialised_data, + headers={"Content-Type": "application/json"}, + timeout=2.0, + ) + rest_client._parse_response.assert_not_called() + + def test_parse_response_json(self): + resp = get_stub_response_json() + rest_client = self._get_rest_client() + deserialised_response, err = rest_client._parse_response(resp) + self.assertEqual(deserialised_response.status, Status.STATUS_SUCCESS) + self.assertEqual(deserialised_response.data["req_guid"], get_static_uuid()) + self.assertEqual(deserialised_response.sent_time, get_static_time()) + self.assertEqual(deserialised_response.code, Code.CODE_OK) + self.assertIsNone(err) + + def test_parse_response_protobuf(self): + resp = get_stub_response_protobuf() + rest_client = self._get_rest_client(wire_type=WireType.PROTOBUF) + deserialised_response, err = rest_client._parse_response(resp) + self.assertEqual(deserialised_response.status, Status.STATUS_SUCCESS) + self.assertEqual(deserialised_response.data["req_guid"], get_static_uuid()) + self.assertEqual(deserialised_response.sent_time, get_static_time_ns()) + self.assertEqual(deserialised_response.code, Code.CODE_OK) + self.assertIsNone(err) + + def test_parse_response_for_non_ok_status(self): + resp = get_stub_response_non_ok_json() + rest_client = self._get_rest_client() + deserialised_response, err = rest_client._parse_response(resp) + self.assertEqual(deserialised_response.status, Status.STATUS_ERROR) + self.assertEqual(deserialised_response.data["req_guid"], get_static_uuid()) + self.assertEqual(deserialised_response.sent_time, get_static_time()) + self.assertEqual(deserialised_response.code, Code.CODE_BAD_REQUEST) + self.assertIsNotNone(err) + self.assertEqual(err.status_code, 404) + + def _get_rest_client(self, serialiser=Serialiser.JSON, wire_type=WireType.JSON): + client_config = ( + RestClientConfigBuilder() + .with_url(self.sample_url) + .with_serialiser(serialiser) + .with_retry_count(self.max_retries) + .with_wire_type(wire_type) + .with_timeout(2.0) + .build() + ) + return RestClient(client_config) diff --git a/clients/python/tests/unit/serde/__init__.py b/clients/python/tests/unit/serde/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/clients/python/tests/unit/serde/json_serde_test.py b/clients/python/tests/unit/serde/json_serde_test.py new file mode 100644 index 00000000..d9723c58 --- /dev/null +++ b/clients/python/tests/unit/serde/json_serde_test.py @@ -0,0 +1,57 @@ +import unittest + +from raccoon_client.protos.raystack.raccoon.v1beta1.raccoon_pb2 import ( + SendEventRequest, + Event, + SendEventResponse, + Status, + Code, +) +from raccoon_client.serde.json_serde import JsonSerde +from tests.unit.rest.client_test import ( + get_static_uuid, + get_static_time_ns, + get_stub_response_json, + get_static_time, +) + + +def get_event_request(): + request = SendEventRequest() + request.req_guid = get_static_uuid() + request.sent_time.FromNanoseconds(get_static_time_ns()) + event = Event() + event.type = "topic 1" + event.event_bytes = b'{"random1": "abc", "xyz": 1}' + request.events.append(event) + return request + + +class JsonSerdeTest(unittest.TestCase): + serde = JsonSerde() + marshalled_event_request = """{"req_guid": "17e2ac19-df8b-4a30-b111-fd7f5073d2f5", "sent_time": {"seconds": 1692250729, "nanos": 234986000}, "events": [{"event_bytes": "eyJyYW5kb20xIjogImFiYyIsICJ4eXoiOiAxfQ==", "type": "topic 1"}]}""" + serialised_event_request = b'{\n "reqGuid": "17e2ac19-df8b-4a30-b111-fd7f5073d2f5",\n "sentTime": "2023-08-17T05:38:49.234986Z",\n "events": [\n {\n "eventBytes": "eyJyYW5kb20xIjogImFiYyIsICJ4eXoiOiAxfQ==",\n "type": "topic 1"\n }\n ]\n}' + + def test_serialise_of_input(self): + event = {"random1": "abc", "xyz": 1} + self.assertEqual(self.serde.serialise(event), b'{"random1": "abc", "xyz": 1}') + + def test_serialise_of_proto_object(self): + event = get_event_request() + serialised_proto = self.serde.serialise(event) + self.assertEqual(serialised_proto, self.serialised_event_request) + + def test_marshaling_of_proto_message(self): + request = get_event_request() + self.assertEqual(self.serde.marshal(request), self.marshalled_event_request) + + def test_unmarshalling_into_proto_message(self): + stub_response = get_stub_response_json()._content + unmarshalled_response = self.serde.unmarshal(stub_response, SendEventResponse()) + self.assertEqual(Status.STATUS_SUCCESS, unmarshalled_response.status) + self.assertEqual(Code.CODE_OK, unmarshalled_response.code) + self.assertEqual(get_static_time(), unmarshalled_response.sent_time) + self.assertEqual(get_static_uuid(), unmarshalled_response.data["req_guid"]) + + def test_content_type_for_json(self): + self.assertEqual("application/json", self.serde.get_content_type()) diff --git a/clients/python/tests/unit/serde/protobuf_serde_test.py b/clients/python/tests/unit/serde/protobuf_serde_test.py new file mode 100644 index 00000000..644cf8c2 --- /dev/null +++ b/clients/python/tests/unit/serde/protobuf_serde_test.py @@ -0,0 +1,63 @@ +import unittest + +from raccoon_client.protos.raystack.raccoon.v1beta1.raccoon_pb2 import ( + SendEventRequest, + Event, + SendEventResponse, + Status, + Code, +) +from raccoon_client.serde.protobuf_serde import ProtobufSerde +from tests.unit.rest.client_test import get_static_uuid, get_static_time_ns + + +def get_stub_request() -> SendEventRequest: + req = SendEventRequest() + req.req_guid = get_static_uuid() + req.sent_time.FromNanoseconds(get_static_time_ns()) + event = Event() + event.type = "click-events" + event.event_bytes = bytes("data bytes for click", "utf-8") + req.events.append(event) + return req + + +def get_marshalled_response(): + return b"\x08\x01\x10\x01\x18\x90\xe8\xc9\x97\xa8\xa2\x85\xbe\x17*0\n\x08req_guid\x12$17e2ac19-df8b-4a30-b111-fd7f5073d2f5" + + +def get_marshalled_request(): + return b"\n$17e2ac19-df8b-4a30-b111-fd7f5073d2f5\x12\x0b\x08\xe9\xe4\xf6\xa6\x06\x10\x90\xb4\x86p\x1a$\n\x14data bytes for click\x12\x0cclick-events" + + +class ProtobufSerdeTest(unittest.TestCase): + serde = ProtobufSerde() + + def test_serialisation_of_proto_input(self): + event = get_stub_request() + serialised_data = self.serde.serialise(event) + expected_serialised_data = get_marshalled_request() + self.assertEqual(expected_serialised_data, serialised_data) + + def test_serialisation_non_proto_input(self): + event = {"a": "b"} + self.assertRaises(ValueError, self.serde.serialise, event) + + def test_marshalling_of_payload(self): + event = get_stub_request() + marshalled_data = self.serde.marshal(event) + expected_marshalled_data = get_marshalled_request() + self.assertEqual(expected_marshalled_data, marshalled_data) + + def test_unmarshalling_of_payload(self): + marshalled_response = bytes(get_marshalled_response()) + unmarshalled_response = self.serde.unmarshal( + marshalled_response, SendEventResponse() + ) + self.assertEqual(Status.STATUS_SUCCESS, unmarshalled_response.status) + self.assertEqual(Code.CODE_OK, unmarshalled_response.code) + self.assertEqual(get_static_time_ns(), unmarshalled_response.sent_time) + self.assertEqual(get_static_uuid(), unmarshalled_response.data["req_guid"]) + + def test_correct_content_type(self): + self.assertEqual("application/proto", self.serde.get_content_type()) diff --git a/clients/python/tests/unit/serde/util_test.py b/clients/python/tests/unit/serde/util_test.py new file mode 100644 index 00000000..8aa101c7 --- /dev/null +++ b/clients/python/tests/unit/serde/util_test.py @@ -0,0 +1,30 @@ +import unittest + +from raccoon_client.serde import util +from raccoon_client.serde.enum import Serialiser, WireType +from raccoon_client.serde.json_serde import JsonSerde +from raccoon_client.serde.protobuf_serde import ProtobufSerde + + +class UtilTest(unittest.TestCase): + def test_serde_factory(self): + serde = util.get_serde(Serialiser.JSON) + self.assertIsInstance(serde, JsonSerde) + + def test_wire_factory(self): + wire = util.get_wire_type(WireType.JSON) + self.assertIsInstance(wire, JsonSerde) + + def test_serde_factory_for_proto(self): + serde = util.get_serde(Serialiser.PROTOBUF) + self.assertIsInstance(serde, ProtobufSerde) + + def test_wire_factory_for_proto(self): + wire = util.get_wire_type(WireType.PROTOBUF) + self.assertIsInstance(wire, ProtobufSerde) + + def test_invalid_wire_factory_input(self): + self.assertRaises(ValueError, util.get_wire_type, "invalid_type") + + def test_invalid_serde_factory_input(self): + self.assertRaises(ValueError, util.get_serde, "invalid_type")