(abstraction-procedures)= # Abstracting procedures ## Basic requirements A procedure in our framework can be a module-level function or a class method. In our example above, we define a module-level function with ```python def hello_world(name: str) -> str: return 'Hello World, ' + name ``` and make this available for the RPC. It's a standard python function, so, nothing special here. **But** there is one important aspect: the so-called _type annotation_ `name: str` and `-> str`. This feature of the python language, introduced with python 3.5, is crucial to the DASF messaging framework. In standard python, type annotations have no impact during runtime. So you can call `hello_world(["ok"])` and it would run the function (and produce an error of course, as it cannot combine `"Hello World, " + ["ok"]`). Only if you would use a static type checker such as `mypy`, it would produce an error without even prior to calling the function, as it realizes that the `hello_world` function accepts a string, and not a list. Within DASF however, we **do use** the type annotation **during runtime**. It is an important part in any web-based framework, **that you need to verify the input**. But instead of adding an additional verification system, we use the type annotations here[^confignote]. If you want to understand the internals of the DASF messaging framework, you should familiarize yourself a bit with type annotations in python. ## How the abstraction works Our framework takes standard python functions or classes and makes them available for a remote procedure call. As a web-based framework, this means that 1. Our framework needs to be able to deserialize JSON requests 2. Our framework needs to be able to validate the requests 3. Our framework needs to be able to serialize JSON requests Let's take our `hello_world` function from above. If you would want to call this function without our framework, you'd need to implement some wrapper that deserializes the request, validates the input and serializes the output, i.e. something like ```python import json def hello_world_request_wrapper(request: str): data = json.loads(request) # deserialize the request assert isinstance(data, dict) # make sure we get a dictionary assert "name" in data # check that the name is in the data assert isinstance(data["name"], str) # make sure the name is a string result = hello_world(name=data["name"]) # call our function return json.dumps(result) # serialize and return the result ``` You can see that such kind of serialization and validation becomes quite verbose. And now imagine you have more complicated procedure calls then our very simple example. A hard-coded verification would add a lot of overhead to the code, just to make the procedures available for RPCs. The abstraction in the DASF works differently and (usually) without additional code. The idea is that when you specify a function such as `def hello_world(name: str) -> str:` we know what we expect. Everything is here: The function, the arguments, the inputs, etc. So what we are doing is, that we take pythons built-in `inspect` module to analyse the function, and [docstring-parser](https://github.com/rr-/docstring_parser) for interpreting the docstring of the procedure. Then we use this information and create a subclass of [pydantics][pydantic-docs] ``BaseModel`` that represents this function (or class or method). ```{note} _pydantic_ is a python package that uses pythons type annotations for runtime validation of data. Moreover, it can be used to serialize and de-serialize input based on the annotations. You can find many examples [in their docs][pydantic-docs]. We recommend that you go through the [overview][pydantic-docs] and familiarize yourself a bit with it before continuing. ``` We'll stick now to our `hello_world` function and look how this is abstracted for the remote procedure call. Classes are handled pretty much the same, but we'll discuss this later in {ref}`abstracting-class`. (abstracting-function)= ## Abstracting functions for RPC When you call the {func}`~demessaging.backend.main` function, it loads the procedures that you make available for the remote procedure call (see {ref}`abstraction-members`). If it encounters a function, it will call the {meth}`~demessaging.backend.function.BackendFunction.create_model` classmethod of the {meth}`demessaging.backend.function.BackendFunction` class. This method creates a subclass of the {meth}`~demessaging.backend.function.BackendFunction` using [pydantics `create_model` function](https://docs.pydantic.dev/latest/concepts/models/#dynamic-model-creation) that represents our `hello_world` function in DASF. In other words, you end up with ```python from demessaging.backend.function import BackendFunction HelloWorldModel = BackendFunction.create_model(hello_world) ``` The ``HelloWorldModel`` class that has been created through this method is comparable to the following class: ```python from demessaging.backend.function import BackendFunction from typing import Literal class HelloWorldModel(BackendFunction): func_name: Literal["hello_world"] = "hello_world" name: str ``` It's a class with two attributes, `func_name` and `name`. `func_name` is an attribute that is always added to these kind of models. It can take exactly one value, and this is the name of the function that it serialized (in this case, `"hello_world"`). The second attribute, `name`, comes from the function that has been called. The `hello_world` function accepts exactly one argument, the `name: str:`, and so this has been added as an attribute to our model. The {meth}`~demessaging.backend.function.BackendFunction.create_model` classmethod does create another model, the return model. Our ``hello_world`` function returns a string and also needs to be serialized, when sent to the client stub. Our return model is a *\_\_root\_\_* model of pydantic and comparable to ```python from pydantic import BaseModel class HelloWorldReturnModel(BaseModel): root: str ``` and is accessible as ``HelloWorldModel.return_model``. ### Calling the procedure Our framework creates an instance of the created `HelloWorldModel` from the request. It then executes the underlying model function via ```python request = HelloWorldModel(name="Peter") result_model = request() # calls the `hello_world` function. `result_model` is an instance of HelloWorldReturnModel assert result_model.root == hello_world(name="Peter") ``` But the thing is, that we can now also create and return JSON from this: ```python request = HelloWorldModel.parse_json('{"name": "Peter"}') result_model = request() result_model.model_dump_json() # gives 'Hello World, Peter' ``` (abstraction-configure-function)= ### Configuring the Function Model Creation You can also change how things are converted from the python function to the pydantic model using the {func}`~demessaging.config.api.configure` function. Passing arguments to this function would be equivalent to setting the ``config`` parameter to the call of {meth}`~demessaging.backend.function.BackendFunction.create_model`. You can give any keyword argument here that is available for the initialization of a {class}`~demessaging.config.backend.FunctionConfig` instance. You can, for instance, add custom `validators` or `field_params`. [pydantic-docs]: https://pydantic-docs.helpmanual.io/ (abstracting-class)= ## Abstracting classes for RPC Instead of using a function as described above, one can also use a method of a python class. Here is an example: ```python class HelloClass: def __init__(self, text: str ="Hello"): self._text = text def hello_world(self, name: str) -> str: return self._text + " World, " + name ``` calling `hello_world("Peter")` is equivalent to calling `HelloClass().hello_world("Peter")`. When we encounter a class in the members that you specified for the {func}`~demessaging.backend.main` function (see {ref}`abstraction-members`), then we will not use the {class}`~demessaging.backend.function.BackendFunction` to create a new model, but instead we use the {class}`~demessaging.backend.class_.BackendClass`. The {meth}`~demessaging.backend.class_.BackendClass.create_model` classmethod of the {class}`~demessaging.backend.class_.BackendClass` uses the same procedure as described in the previous section ({ref}`abstracting-function`) to abstract the ``__init__`` method of the class. And it does an abstraction for all the methods of the class. The pydantic representation of our class, i.e. the ``HelloClassModel`` in ```python from demessaging.backend.class_ import BackendClass HelloClassModel = BackendClass.create_model(HelloClass) ``` is comparable to: ```python from demessaging.backend.class_ import BackendClass from demessaging.backend.function import BackendFunction class HelloClassHelloWorldModel(BackendFunction): func_name: Literal["hello_world"] = "hello_world" name: str class HelloClassModel(BackendClass): class_name: Literal["HelloClass"] = "HelloClass" text: str = "Hello" function: HelloClassHelloWorldModel ``` and you would create an instance of this method via ```python request = HelloClassModel( text="Hello", function={"func_name": "hello_world", "name": "Peter"} ) ``` Hence, you specify the method that you want to call. Running `request()` now creates an instance of `HelloClass` and runs it's `hello_world` method, i.e. ```python response = HelloClassModel() response.root == HelloClass(text="Hello").hello_world(name="Peter") ``` ### Configuring the Class Model Creation You can configure classes as you can {ref}`configure functions `, just decorate your class with the {func}`~demessaging.config.api.configure` function and pass the parameters for the {class}`~demessaging.config.backend.ClassConfig`. You can, for instance, use the `methods` parameter to specify what methods will be available for the RPC. You can also decorate any method of the class with the {func}`~~demessaging.config.api.configure` function as you do with normal functions. [^confignote]: nevertheless, you can also add your own validators using the {func}`~demessaging.config.api.configure` function