-
Notifications
You must be signed in to change notification settings - Fork 0
BasicConnectorTutorial
The following tutorial shows how to implement a minimal
full featured YAML database connector. This is the required starting point of any
connector. It will only support direct initialization of DatabaseItems without
custom attributes. This means the generated JSON-like structures will always match
the structure of the database, so the item you want to initialize with it must
also match the database structure.
This might enough in following scenarios:
- a connector to a YAML file that directly store items in the YAML structure
- a connector to an SQL database that returns the values of all columns in a table, formatted as a JSON-like dict
- a connector to an external API that returns GET JSON responses, and expects the input JSON-like dict as POST payload to create items.
- a connector to an LDAP database that returns the values of all attributes in an entry, formatted as a JSON-like dict.
- ...
However, if you need any transformation of the database result (i.e. renaming a field, search for an attribute in a field at a custom path, etc), you'll need to extend the basic connector with custom attributes, as specified in the Advanced Connector Tutorial.
The first step is to define a DatabaseConnection to execute requests on a YAML
file. The connection is initilized with a path to the YAML file we want to use
as a database, and a path to the YAML structure where objects will be stored.
For example, if yaml_path is ["objects"], the connection will only consider
YAML objects under the objects key.
# yaml/connection.py
import pathlib
from connecto.connection import DatabaseConnection
class YamlConnection(DatabaseConnection):
"""Connection to a YAML file.
:param file_path: Path to the YAML file
:param yaml_path: A nested data path to the root of the collection
within the file.
"""
def __init__(self, file_path, yaml_path=None):
self.file_path = pathlib.Path(file_path)
if yaml_path is None:
self.yaml_path = []
else:
self.yaml_path = yaml_pathThen we need to give to the connection the ability to search items in the YAML
file. To do so, we define a new YamlSearchRequest class that will contain
search parameters required to execute a search. In this case, the path to look
for in the YAML file is required. We also define a convenient response object
that will store results of search operations.
# yaml/request.py
from typing import Any
from dataclasses import dataclass
@dataclass
class YamlSearchRequest:
path: list[str|int]
@dataclass
class YamlSearchResponse:
value: AnyRemember that a request can be anything. It defines what will be passed to the
execute_search method. In this case, the execute_search methods needs to:
- Open and read the YAML file database
- Fetch the object associated at the path specified in the request
- Raise a
ItemNotFoundif no item associated to the key was found
To do so, we use the utility library from connecto.utils.nested_data_path that
allows to conveniently query items in a nested dict/list structure.
# yaml/connection.py
from yaml import load, Loader
from connecto.utils.nested_data_path import find
...
from connecto.request import YamlSearchRequest, YamlSearchResponse
from connecto.error import ItemNotFound
class YamlConnection(DatabaseConnection):
...
def execute_search(self, yaml_search_request):
with open(self.file_path, "r", encoding="utf_8") as yaml_database:
database = load(yaml_database.read(), Loader=Loader)
try:
return YamlSearchResponse(
# The request search path is relative to the base YAML path
find(database, self.yaml_path + yaml_search_request.path)
)
except KeyError as e:
raise ItemNotFound(request.path, self.file_path) from eNow we need features to define a YAML DatabaseItem and actually query the
database. The first things to provide is an ItemMapper. Here we define an item
mapper that uses YAML object keys as _ids.
Considering the definition of our previous structure, the implementation of the item mapper is trivial.
# yaml/mapper.py
from connecto.item import ItemMapper
from connecto.request import YamlSearchRequest
class MapByKey(ItemMapper):
def search_request(self, _id):
return YamlSearchRequest([_id])
def load(self, _init_value, base_search_response):
return base_search_response.valueThe search_request creates a query to the item at path [_id]. The connection
will intepret this path relative to the optional yaml_path, but the
ItemMapper does not need to know about it.
The load method is used to initialize the item from the response of the base
search_request. The value of the response has been defined in the previous
execute_search implementation: it is the nested object at the specified path
in the YAML structure.
Finally, we define a custom YamlItem with convenient defaults to improve user experience:
# yaml/item.py
from connecto.item import ItemMapper, DatabaseItem
...
class YamlItem(DatabaseItem):
def __init__(self, item_mapper=MapByKey(), model=None):
if model is None:
super().__init__(item_mapper, {})
else:
super().__init__(item_mapper, model)# yaml/engine.py
from connecto.engine import DatabaseEngine
from .connection import YamlConnection
from .item import YamlItem
class YamlEngine(DatabaseEngine):
def __init__(self, file_path, database_item=YamlItem(), yaml_path=None):
super().__init__(YamlConnection(file_path, yaml_path), database_item)It's now possible to search items in a YAML file:
# examples/yaml/search.py
import yaml
from connecto.yaml.engine import YamlEngine
def init_data(path):
with open(path, "w") as yaml_file:
yaml_file.write(
yaml.dump({"1": {"name": "pipo", "gid": 12, "description": "Example user"}})
)
if __name__ == "__main__":
init_data("test.yaml")
yaml_engine = YamlEngine("test.yaml")
print(yaml_engine.search("1"))$ uv run examples/yaml/search.py
("1", {'description': 'Example user', 'gid': 12, 'name': 'pipo'})
We will now implement features required to add new objects to the YAML database.
First, a YamlCreateRequest object is required, with the associated response
containing the new ID of the created item.
# yaml/request.py
...
@dataclass
class YamlCreateRequest:
path: list[str|int]
value: Any
created_id: str|None = None
@dataclass
class YamlCreateResponse:
created_id: strThe previous MapByKey item mapper can be extended to support item creation:
# yaml/mapper.py
import copy
import uuid
...
from .request import YamlSearchRequest, YamlCreateRequest
def uuid4():
return str(uuid.uuid4())
class MapByKey(ItemMapper):
def __init__(self, generate_id=uuid4):
self.generate_id = generate_id
...
def created_id(self, create_response: YamlCreateResponse):
return create_response.created_id
def create_request(self, item_value):
_id = self.generate_id()
# Use a deep copy of values for the base request so that attributes can
# modify it later without side effects
return YamlCreateRequest([_id], copy.deepcopy(item_value), _id)In the case of YAML, we use the generated_id method to created a unique id. In
some other database engine, the id might be automatically created by the
database, and returned in a custom field of the custom DatabaseCreateResponse.
The created_id method allows to retrieve the ID of the new item from the
database response.
Finally, allow the YamlConnection to create new entries, generating a new ID
for each item:
# yaml/connection.py
...
from connecto.utils.nested_data_path import find, update
...
from .request import (
YamlSearchRequest,
YamlSearchResponse,
YamlCreateRequest,
YamlCreateResponse,
)
...
class YamlConnection(DatabaseConnection):
...
def execute_create(self, request: YamlCreateRequest):
database = None
with open(self.file_path, "r", encoding="utf_8") as yaml_database:
# Init database as an empty dict if the file is empty
database = load(yaml_database.read(), Loader=Loader) or {}
# The path of the item to update is relative to the base YAML path
update(
database,
self.yaml_path + request.path,
request.value,
)
with open(self.file_path, "w", encoding="utf_8") as yaml_database:
yaml_database.write(dump(database, Dumper=Dumper))
return YamlCreateResponse(request.created_id)It's now possible to create items using the YamlEngine:
# examples/yaml/create.py
from connecto.yaml.engine import YamlEngine
if __name__ == "__main__":
yaml_engine = YamlEngine("test.yaml")
item_id = yaml_engine.create(
{"name": "pipo", "gid": 12, "description": "Example user"}
)
print(f"Item {item_id} created.")
print(yaml_engine.search(item_id))$ uv run examples/yaml/create.py
Item df8a3272-c829-4a2b-b31f-a3e18d937ce4 created.
('df8a3272-c829-4a2b-b31f-a3e18d937ce4', {'description': 'Example user', 'gid': 12, 'name': 'pipo'})
Following the same scheme as before, we define a request type and the associated
response for a delete operation. The response currently does not need to return
anything as no DatabaseItem process is required once delete requests have been
executed.
# yaml/request.py
...
@dataclass
class YamlDeleteRequest:
path: list[str|int]
@dataclass
class YamlDeleteResponse:
passHere is how to process the request using the YamlConnection:
# yaml/connection.py
...
from connecto.utils.nested_data_path import find, update, delete
from .request import (
...
YamlDeleteRequest,
YamlDeleteResponse
)
...
class YamlConnection(DatabaseConnection):
...
def execute_delete(self, request: YamlDeleteRequest):
database = None
with open(self.file_path, "r", encoding="utf_8") as yaml_database:
# Init database as an empty dict if the file is empty
database = load(yaml_database.read(), Loader=Loader) or {}
try:
# The path of the item to delete is relative to the base YAML path
delete(database, self.yaml_path + request.path)
except KeyError:
# Deleting an object that do not exist is not an error
pass
with open(self.file_path, "w", encoding="utf_8") as yaml_database:
yaml_database.write(dump(database, Dumper=Dumper))
return YamlDeleteResponse()Finally, define how the item mapper should create delete requests:
# yaml/item.py
...
from .request import YamlSearchRequest, YamlCreateRequest, YamlDeleteRequest
class MapByKey(IdMapper):
...
def delete_request(self, _id):
return YamlDeleteRequest([_id])It's now possible to delete items using the YamlEngine:
# example/yaml/delete.py
from connecto.yaml.engine import YamlEngine
if __name__ == "__main__":
yaml_engine = YamlEngine("test.yaml")
item_id = yaml_engine.create(
{"name": "pipo", "gid": 12, "description": "Example user"}
)
print(f"Item {item_id} created.")
print(yaml_engine.search(item_id))
yaml_engine.delete(item_id)
print(f"Item {item_id} deleted.")$ uv run examples/yaml/delete.py
Item a463b384-a7ee-41ee-88ae-052dde322b35 created.
('a463b384-a7ee-41ee-88ae-052dde322b35', {'description': 'Example user', 'gid': 12, 'name': 'pipo'})
Item a463b384-a7ee-41ee-88ae-052dde322b35 deleted.
The database save() operation consists in updating the attributes of an
already existing item.
To perform the operation on a YAML database, the request must contain the path
associated to the item to update, and the up-to-date values of the item. There
is currently nothing to return from the update operation in the response.
# yaml/request.py
...
@dataclass
class YamlUpdateRequest:
path: list[str|int]
value: Any
@dataclass
class YamlUpdateResponse:
passProcess the request using the YamlConnection:
# yaml/connection.py
...
from connecto.utils.nested_data_path import find, update, delete
from .request import (
...
YamlUpdateRequest,
YamlUpdateResponse
)
...
class YamlConnection(DatabaseConnection):
...
def execute_update(self, request: YamlUpdateRequest):
database = None
with open(self.file_path, "r", encoding="utf_8") as yaml_database:
# Init database as an empty dict if the file is empty
database = load(yaml_database.read(), Loader=Loader) or {}
# The path of the item to update is relative to the base YAML path
update(
database,
self.yaml_path + request.path,
request.value,
)
with open(self.file_path, "w", encoding="utf_8") as yaml_database:
yaml_database.write(dump(database, Dumper=Dumper))
return YamlDeleteResponse()Finally, enable update in the MapByKey item mapper:
# yaml/item.py
...
from .request import (
YamlSearchRequest,
YamlCreateRequest,
YamlDeleteRequest,
YamlUpdateRequest,
)
class MapByKey(IdMapper):
...
def update_request(self, _id, value):
# Use a deep copy of values for the base request so that attributes can
# modify it later without side effects
return YamlUpdateRequest([_id], copy.deepcopy(value))The YamlEngine can now perform item updates:
# examples/yaml/update.py
from connecto.yaml.engine import YamlEngine
if __name__ == "__main__":
yaml_engine = YamlEngine("test.yaml")
item_id = yaml_engine.create(
{"name": "pipo", "gid": 12, "description": "Example user"}
)
print(f"Item {item_id} created.")
print(yaml_engine.search(item_id))
item_id, item = yaml_engine.search(item_id)
item["name"] = "molo"
item["description"] = "Updated user"
yaml_engine.save(item_id, item)
print(f"Item {item_id} updated.")
print(yaml_engine.search(item_id))$ uv run examples/yaml/update.py
Item f3ef7467-3ec7-4acd-ba89-42d256736fb5 created.
('f3ef7467-3ec7-4acd-ba89-42d256736fb5', {'description': 'Example user', 'gid': 12, 'name': 'pipo'})
Item f3ef7467-3ec7-4acd-ba89-42d256736fb5 updated.
('f3ef7467-3ec7-4acd-ba89-42d256736fb5', {'description': 'Updated user', 'gid': 12, 'name': 'molo'})
Item selection is used to retrieve multiple items matching a filter from the
database. connecto is only responsible to perform as best as possible to
convert the filter into efficient database requests, if it's possible.
Indeed, the specification of the select operation states that it must at
least return all items matching the filter. This means always returning all
items is a valid result for any filter.
Since a YAML is not a real database with selection features, select will be implemented ignoring the filter.
First, a YamlSelectRequest object is required, with the associated response
containing an {id: value} dict of found items. Each value is the value at
path for each item.
# yaml/request.py
...
@dataclass
class YamlSelectRequest:
path: list[str | int]
@dataclass
class YamlSelectResponse:
values: dict[str | int, Any]The execution of select requests can be implemented as follows in the
YamlConnection:
...
from .request import (
...
YamlSelectRequest,
YamlSelectResponse,
)
class YamlConnection(DatabaseConnection):
...
def execute_select(self, request: YamlSelectRequest):
database = None
with open(self.file_path, "r", encoding="utf_8") as yaml_database:
# Init database as an empty dict if the file is empty
database = load(yaml_database.read(), Loader=Loader) or {}
items = {}
# Consider items at base yaml_path
for key, value in find(database, self.yaml_path).items():
# Gets the value at request.path within each item
items[key] = find(value, request.path)
return YamlSelectResponse(items)Note
The current implementation assumes the collection of items is organized as an {id: value} dictionnary in the YAML file, as specified by the MapByKey item mapper. If other item mappers should be supported (for example, to find item within a list using an attribute as ID), the connection should be adapted to support this is select. This can be performed with a new select request type that is configured by each item mapper to give the YamlConnection an hint on how to manage items.
The item mapper then needs to implement the following methods to support selection:
# yaml/request.py
...
class MapByKey(ItemMapper):
...
def select_request(self, _item_filter):
# Use [] as path to select the complete item at each key
return YamlSelectRequest([])
def load_items(self, _init_factory, base_request_response: YamlSelectResponse):
return list(base_request_response.values.items())
def select_response(
self,
_id,
base_request_response: YamlSelectResponse,
_attribute_responses: YamlSelectResponse,
):
# Base response for the item associated to _id
base_response = YamlSearchResponse(base_request_response.values[_id])
return base_response, NoneThe load_items method is assumed to return the base initialization of all
selected items in a list of (_id, value). The usage of the _init_factory
if explained in the AdvandedTutorial.
The purpose of the select_response method is to build a tuple of request that
will be pass to the load() method of the item associated to _id. Considering
our implementation of YamlItem.load(), this must be an object with a value
attribute, so we reuse the YamlSearchResponse object for convenience. This is
actually a good practice, as each item is loaded as if data was returned from
a search request for each item. Since no custom attribute as been defined yet,
we can ignore the _attribute_responses parameter and return None as response
for attributes.
Finally, the select operation can be performed as follows:
# examples/yaml/select.py
import json
from connecto.yaml import YamlEngine, YamlItem, YamlAttribute
if __name__ == "__main__":
yaml_engine = YamlEngine(
"test.yaml", database_item=YamlItem(model={"login": YamlAttribute(["name"])})
)
yaml_engine.create({"login": "pipo", "gid": 12, "description": "Example user"})
yaml_engine.create({"login": "molo", "gid": 13, "description": "Other user"})
print("Selected items:")
print(json.dumps(yaml_engine.select("*"), indent=2))$ uv run examples/yaml/select.py
Selected items:
[
[
"8c59f779-a6cf-44bc-be0f-7e032ff0c72f",
{
"description": "Other user",
"gid": 13,
"login": "molo"
}
],
[
"a0aff032-0dd8-481f-bdfe-4dccc3b6a66f",
{
"description": "Example user",
"gid": 12,
"login": "pipo"
}
]
]The current implementation provides a full featured YAML database connection that would be enough for some real use cases. Notice that the implementation of each operation is optional: it is valid to implement a connector that only support the search operation.
However, it is not possible to customize the structure of the JSON-like structure of items: it is assumed to exactly match the structure of the item in the YAML file.
This might be limiting when connecting to an existing YAML file with a scheme that does not match the expected JSON-like structure.
To handle such cases, connecto supports the implementation of custom
DatabaseAttributes that allows to specify how to map each attribute to the
backend database. This is detailed in the Advanced Connector
Tutorial.