Building API clients ==================== The reason Typefit was built is to consume APIs. But instead of just letting you handle all the boring boilerplate and telling you to parse the output of your functions with :py:func:`typefit.typefit`, it will handle the boilerplate for you and just let you describe what your API looks like. This guide is intended to cover the main aspects of the Typefit API generator but it is advised to read it in the writing order since concepts are introduced progressively over the page. .. note:: I can hear your thoughts out loud right now, thinking that this is neat but for real production APIs it's probably just an impractical dream. Well, maybe but one of the design goals is to have many orthogonal concepts that you can easily customize individually. Please open an issue if you find an API that you can't work with using Typefit! In this page we'll use `httpbin `_ to demonstrate how you can make use of the API client generator. .. note:: This whole thing depends on ``httpx`` however Typefit doesn't depend on it by default so you need to add ``httpx`` to the requirements of your project. Defining models --------------- The first step in building an API client is to define your models. To begin with we'll do a GET query, so let's build the GET model. .. code-block:: python HttpArg = Union[Text, List[Text]] HttpArgs = Dict[Text, HttpArg] HttpHeaders = Dict[Text, Text] class HttpGet(NamedTuple): args: HttpArgs headers: HttpHeaders origin: Text url: Text This lets us define what the output from the server should look like. That's the type into which the response will be fitted. Making a GET request -------------------- Then let's build our API: .. code-block:: python from typefit import api class Bin(api.SyncClient): BASE_URL = "https://httpbin.org/" @api.get("get?value={value}") def get(self, value: Text) -> HttpGet: pass The API client generator will look at the signature of your methods and generate the code that is required for this method to work. You need not write any code inside the method, it will never be called. As you can see, the specified URL is formatted using the standard `format `_ syntax and the method's arguments, except that all arguments will be safely URL-encoded. .. code-block:: python get = Bin().get("foo bar") assert get.args["value"] == "foo bar" Let's walk through what happens: 1. :code:`get("foo bar")` is called 2. The path template ``get?value={value}`` becomes the path ``get?value=foo+bar`` 3. The path is joined to the ``BASE_URL`` to become `https://httpbin.org/get?value=foo+bar `_ 4. The request is made 5. The output JSON is fitted inside ``HttpGet`` and returned .. note:: It is expected that the API will return JSON data. If not, please see below how to change API output decoding. Alternate specification of GET query ++++++++++++++++++++++++++++++++++++ To specify the GET parameters that you're going to send you might prefer using the ``params`` parameter which takes a dictionary as argument and allows you to define the GET query to be sent. The above example would be equivalent to: .. code-block:: python from typefit import api class Bin(api.SyncClient): BASE_URL = "https://httpbin.org/" @api.get("get", params: lambda value: {"value": value}) def get(self, value: Text) -> HttpGet: pass That is more verbose but in some cases much more convenient to build. Complex URL generation ---------------------- In case you need a more complex URL generation method, you can provide a callable as path instead of just giving a static string. By example: .. code-block:: python from typefit import api class Bin(api.SyncClient): BASE_URL = "https://httpbin.org/" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.count = 0 def get_url(self, value: int) -> Text: """ This method counts the number of requests done and adds a parameter which indicates if this request is odd or even. """ self.count += 1 if self.count % 2: parity = "odd" else: parity = "even" return f"get?parity={parity}&value={value}" @api.get(get_url) def get(self, value: int) -> HttpGet: pass Here you can see that a callable is passed to generate the path. The callable is called for each request. It receives its arguments based on their name: if they have the same name as the arguments found in the method's signature then it will get called. This callable can be an unbound method (like here), an inline lambda or an external function. Any callable will do as long as the arguments have the right name! .. code-block:: python b = Bin() print(b.get(42).args) # {'parity': 'even', 'value': '42'} print(b.get(42).args) # {'parity': 'odd', 'value': '42'} Custom headers -------------- Almost every API will require that you provide some form of authentication in the headers. Typefit provides you several ways to specify custom headers. .. code-block:: python from typefit import httpx_models as hm class Bin(api.SyncClient): BASE_URL = "https://httpbin.org/" def headers(self) -> Optional[hm.HeaderTypes]: return {"Authorization": "Bearer xxxxxxxx"} @api.get("get") def get(self) -> HttpGet: pass get = Bin().get() assert get.headers["Authorization"] == "Bearer xxxxxxxx" You can see that there is a ``headers()`` method that you can implement in order to provide headers which will be added to each request. The method is evaluated before each call so you can change the return value over time. .. note:: You'll notice that the ``headers()`` method's return type is ``hm.HeaderTypes``. Here, ``hm`` is an alias of the ``typefit.httpx_models`` module, since the whole API client generator is based on `httpx `_. The reasons behind that choice over the extra-popular ``requests`` are multiple: - ``httpx`` enforces timeouts while ``requests`` doesn't, which is dangerous since lingering requests can hang a thread or a process forever and cause serious reliability issues - ``httpx`` is fully type annotated, which is really fitting with the mood of this module - And finally, ``httpx`` supports a synchronous and asynchronous API and provides a pluggable system to manage connection pooling (either via the async loop, either via threading, etc) Overall, **the API exposed by Typefit is very transparent above httpx**. This is really just some boilerplate over ``httpx``. But if instead of defining global headers you want to control the headers for one specific request, you can also specify your headers in the decorator. .. code-block:: python @api.get("get", headers={"Authorization": "Bearer xxxxxxxx"}) def get(self) -> HttpGet: pass And finally, you'll quickly realize that all parameters of that decorator can either be static values, either be callables. The same rules apply as explained above. .. code-block:: python @api.get("get", headers=lambda bearer: { "Authorization": f"Bearer {bearer}", }) def get(self, bearer: Text) -> HttpGet: pass Such a code lets you define headers that depend on the method's parameters directly. Passed parameters are matched based on their name, so here the ``bearer`` parameter from the method will get passed to the ``headers`` lambda when evaluated. .. note:: When you specify headers through *both* the ``headers()`` method and the decorator parameter, then both values will be merged with a priority given to the decorator's parameter. Sending POST/PUT/PATCH data --------------------------- Of course, no API generator could be complete if it didn't allow you to send actual POST data to the API. As Typefit uses ``httpx``, all three arguments are exposed through the constructor: - ``data`` is regular form data, it's basically a dictionary with all the data that will get form-encoded - ``json`` will serialize the provided object into JSON and send it to the API with the appropriate ``Content-Type`` header - ``files`` contains a dictionary of files to be sent in a multipart-encoded form .. note:: As for every other parameter, you can either pass a static value either pass a callable which will be invoked for each request. Once the value resolved, it is passed as-is to ``httpx``. To get more detailed information you should have a look at `the official httpx documentation `_ as well as the `httpx types definitions `_. Let's consider that we want to send JSON data to the remote API, because that's usually what we want to do. The simplest thing we can do is just to send static data, by example: .. code-block:: python class Bin(api.SyncClient): BASE_URL = "https://httpbin.org/" @api.post("post", json={"foo": "bar"}) def my_post_method(self) -> HttpPost: pass bin = Bin() post = bin.my_post_method() assert post.json == {"foo": "bar"} Of course, this can be replaced by a dynamic value based on the method's parameters. .. code-block:: python class Bin(api.SyncClient): BASE_URL = "https://httpbin.org/" @api.post("post", json=lambda foo: {"foo": foo}) def my_post_method(self, foo: Text) -> HttpPost: pass bin = Bin() post = bin.my_post_method("bar") assert post.json == {"foo": "bar"} Using this callable technique, you can send arbitrary data to the API based on your parameters, even if the parameters are complex Python types. It is worth noting that just like above the ``json`` callable could be any callable, including an actual method of the object. And finally, this example uses the ``@api.post`` decorator but the ``@api.put`` and ``@api.patch`` are also available with the same syntax. JSON serialization ++++++++++++++++++ Since you've built nice model classes you might also want to use them in the ``json`` argument that will be serialized into the request's body. The client automatically makes use of typefit's :py:func:`~.typefit.serialize.serialize` function to convert anything you pass through the ``json`` parameter. Let's change the previous example a little bit: .. code-block:: python class SomeObj(NamedTuple): foo: Text class Bin(api.SyncClient): BASE_URL = "https://httpbin.org/" @api.post("post", json=lambda foo: SomeObj(foo)) def my_post_method(self, foo: Text) -> HttpPost: pass bin = Bin() post = bin.my_post_method("bar") assert post.json == {"foo": "bar"} Of course you can override the serializer being used and insert any serializer that you want. You could either extend :py:class:`~.typefit.serialize.Serializer` class or implement your own serializer, including using a Django Rest Framework serializer or making a simple pass-through function like this: .. code-block:: python class MyApi(api.SyncClient): # ... your code ... def init_serializer(self): return lambda x: x Other arguments and parameters ------------------------------ Eventually the goal is to support fully ``httpx``, however the goal of this specific release is to support the bare minimum in order for the library to be viable. The core use cases are detailed above, however more parameters are translated today. Overall logic +++++++++++++ The building of a request lies on 3 different layers: 1. The API client builder is based on httpx's `Client `_ so cookies will be persisted from one request to the other 2. Overridable methods of the :py:class:`typefit.api.SyncClient` allow you set values indistinctly on all requests 3. The decorator of your HTTP methods also allows you to set those values individually for each method The idea is that parameters are evaluated in order and then merged or overridden (depending on which makes sense) with the next layer. So by example if you have a cookie persisted in the Client and you define another cookie in the method's decorator then both cookies will be sent. On the other hand if a cookie is persisted in the Client but the decorator sets the same cookie then the decorator's value will be sent instead of the persisted one. Decorator arguments evaluation ++++++++++++++++++++++++++++++ To give you a total flexibility in what you can send from the decorator, all arguments can either be a static value or a callable. You can observe both cases in the examples above. If callable, the callable will be called for each request and arguments found to have the same name as an argument in the generated method will be automatically passed to the callable Values are passed as-is to ``httpx`` and thus you can refer to the ``httpx`` documentation for a full reference. Available arguments +++++++++++++++++++ In addition to the request path and the POST/GET/PUT data parameters that have been explained above, those ``httpx`` arguments are available. ``headers()`` ~~~~~~~~~~~~~ This has been mentioned before but is worth repeating here. .. code-block:: python from typefit import httpx_models as hm class Bin(api.SyncClient): BASE_URL = "https://httpbin.org/" def headers(self) -> Optional[hm.HeaderTypes]: return {"Foo": "Bar"} @api.get("get", headers={"Answer": "42"}) def get(self) -> HttpGet: pass get = Bin().get() assert get.headers = { "Foo": "Bar", "Answer": "42", } Headers can be returned by the :py:meth:`typefit.api.SyncClient.headers` method or come from the decorator's arguments. To demonstrate the resolution of callable arguments, you can also do .. code-block:: python @api.get("get", headers=lambda answer: {"Answer": str(answer)}) def get(self, answer: int) -> HttpGet: pass ``cookies()`` ~~~~~~~~~~~~~ Cookies work exactly the same way as headers do. The only difference is that cookies set by the server will persist afterwards. Here is how that would work: .. code-block:: python class HttpCookies(NamedTuple): cookies: Dict[Text, Text] class Bin(api.SyncClient): BASE_URL = "https://httpbin.org/" def cookies(self) -> Optional[hm.CookieTypes]: return {"global_cookie": "hello"} @api.get("cookies/set/{name}/{value}") def set_cookie(self, name: Text, value: Text) -> HttpCookies: pass @api.get("cookies", cookies={"local_cookie": "hi"}) def get_cookies(self) -> HttpCookies: pass bin = Bin() bin.set_cookie("set_cookie", "howdy") assert bin.get_cookies().cookies == { "set_cookie": "howdy", "global_cookie": "hello", "local_cookie": "hi", } This example demonstrates how you can set a cookie in the session (through httpbin's cookie setting feature), have one coming from the global method and one defined locally on the ``get_cookies()`` method. All three cookies get merged and httpbin's ``cookies`` call returns the three of them. ``auth()`` ~~~~~~~~~~ On the contrary to cookies and headers, you can only send one auth for each request. If you want to specify the auth parameter you can return a non-``None`` value. The value specified in the decorator will of course take precedence over the value defined in the ``auth()`` method. .. code-block:: python class HttpAuth(NamedTuple): authenticated: bool user: Text class Bin(api.SyncClient): BASE_URL = "https://httpbin.org/" def auth(self) -> Optional[hm.AuthTypes]: return "test_user", "test_password" @api.get("basic-auth/test_user/test_password") def success_auth(self) -> HttpAuth: pass @api.get("basic-auth/test_user/test_password", auth=("wrong", "wrong")) def fail_auth(self) -> HttpAuth: pass bin = Bin() assert bin.success_auth().authenticated assert not bin.success_auth().authenticated # this will raise an exception There we use the ``basic-auth/test_user/test_password`` call of httpbin which will trigger an HTTP basic auth using ``test_user`` as username and ``test_password`` as password. We defined a ``success_auth()`` method which has no ``auth`` parameter in its arguments, meaning that the value of the ``auth`` parameter given to ``httpx`` is determined by the output of the ``auth()`` method. There is also a ``fail_auth()`` method, which overrides the ``auth`` argument with a ``("wrong", "wrong")`` tuple. This will cause the authentication to fail and an exception to be risen because those are not the username/passwords that the API will expect. ``allow_redirect()`` ~~~~~~~~~~~~~~~~~~~~ Indicates to ``httpx`` if it should follow redirects or not. Like ``auth``, it is not something you can merge so if the decorator returns a value it will override the output of the method. .. code-block:: python class Bin(api.SyncClient): BASE_URL = bin_url def follow_redirects(self) -> bool: return False @api.get("redirect/1", follow_redirects=True) def redirect(self) -> HttpGet: pass We disable redirects globally using the method but specifically for the ``redirect()`` method we override that and define that we allow redirects. .. code-block:: python redirect = Bin().redirect() assert redirect.url.endswith("/get") As a result, when you execute the query and check the output, you'll notice that we have been sent to the the ``/get`` endpoint. Decoding responses ------------------ By default, the client makes a few assumptions: - The remote API will respect HTTP status codes and thus if the server is returning an error it will show in the status code - The reply will always be a JSON document - The whole document is to be fitted into a type However, it is far from always being true. For this reason, it exists methods that you can override in order to alter the decoding process. Now, let's suppose that instead of using a regular JSON REST API we're dealing with some smarty pants who thought that it'd be simpler to output YAML. Hypothetically, it would return those replies to the example requests. ``GET /article/42`` .. code-block:: yaml status: "ok" result: id: 42 title: "I love sausages" content: "Sausages are good, I love sausages" ``GET /article/nope`` .. code-block:: yaml status: "error" result: message: "Not found" ``decode()`` ++++++++++++ Very obviously you can't rely on the default decode method which will expect some JSON. .. code-block:: python def decode(self, resp: httpx.Response, hint: Any) -> Any: return yaml.safe_load(resp.text) ``raise_errors()`` ++++++++++++++++++ Now we also want to know if our request was successful .. code-block:: python def raise_errors(self, resp: httpx.Response, hint: Any) -> None: super().raise_errors(resp, hint) data = self.decode(resp, hint) if data.get("status") != "ok": raise httpx.HTTPError We keep the call to the parent which will check the HTTP status code (we never know maybe there will be an error 5xx) but then we decode the YAML value and raise an error if we can't find the ``status: "ok"`` in the YAML document. ``extract()`` +++++++++++++ The last part of our pipeline would be the extract function, which takes the interesting value out and returns it. .. code-block:: python def extract(self, data: Any, hint: Any) -> Any: return data["result"] It's the content of the ``result`` key that interests us for type-fitting, so that's what we're returning. Overall +++++++ Let's now put this together. .. code-block:: python class Article(NamedTuple): id: int title: Text content: Text class StupidApiClient(api.SyncClient): BASE_URL = "http://stupid.api/v1/" def decode(self, resp: httpx.Response, hint: Any) -> Any: return yaml.safe_load(resp.text) def raise_errors(self, resp: httpx.Response, hint: Any) -> None: super().raise_errors(resp, hint) data = self.decode(resp, hint) if data.get("status") != "ok": raise httpx.HTTPError def extract(self, data: Any, hint: Any) -> Any: return data["result"] @api.get("article/{id}") def get_article(self, id: int) -> Article: pass And then you can call the Stupid API (well you could if it existed): .. code-block:: python stupid_api = StupidApiClient() article = stupid_api.get_article(42) assert article.title == "I love sausages" Hint ++++ You'll notice the ``hint`` argument at we've ignored so far. That's because some APIs like to make the data extraction complicated by putting the data in a different key every time. Let's consider an API that lets us retrieve ``broccoli`` and ``carrots``. ``GET /broccoli/42`` .. code-block:: javascript { "broccoli": { "tastes_good": false } } ``GET /carrots/42`` .. code-block:: javascript { "carrot": { "rabbits_like_it": true } } Thanks to the hint, we're going to be able to know how to decode our data. .. code-block:: python class Broccoli(NamedTuple): tastes_good: bool class Carrot(NamedTuple): rabbits_like_it: bool class VegetableApi(api.SyncClient): BASE_URL = "http://vegetable.api/v1/" def extract(self, data: Any, hint: Any) -> Any: return data[hint] @api.get("broccoli/{id}", hint="broccoli") def get_broccoli(self, id: int) -> Broccoli: pass @api.get("carrot/{id}", hint="carrot") def get_carrot(self, id: int) -> Carrot: pass Here the hint is just a string but in fact it could be anything. It is forwarded to your functions without interpretation in the middle, that value is totally opaque to Typefit. .. note:: Broccoli here is simply used as an example and tries in no way to be harmful to the Broccoli Lovers community. Everyone is entitled to like or dislike whichever food they want even if this choice tastes incredibly bad like broccoli. Also I have a broccoli-loving friend. Reference --------- You will find here the detailed reference of all available decorators and classes in the API client builder module. .. automodule:: typefit.api :members: