API client implementation#
This guide describes the conventions for implementing a Foundry DevTools APIClient
.
The api_name attribute#
Every APIClient
implementation needs to set the class attribute api_name
.
The api_request method#
The api_request
method is a special request function, that can be used to easily make API requests to Foundry.
def api_request(
self,
method: str | bytes,
api_path: str,
params: _Params | None = None,
data: _Data | None = None,
headers: dict | None = None,
cookies: RequestsCookieJar | _TextMapping | None = None,
files: _Files | None = None,
auth: _Auth | None = None,
timeout: _Timeout | None = None,
allow_redirects: bool = True,
proxies: _TextMapping | None = None,
hooks: _HooksInput | None = None,
stream: bool | None = None,
verify: _Verify | None = None,
cert: _Cert | None = None,
json: Incomplete | None = None,
error_handling: ErrorHandlingConfig | Literal[False] | None = None,
) -> Response:
"""Make an authenticated request to the Foundry API.
The `api_path` argument is only the api path and not the full URL.
For https://foundry/example/api/method/... this would be only,
"method/...".
Args:
method: see :py:meth:`requests.Session.request`
api_path: **only** the api path
params: see :py:meth:`requests.Session.request`
data: see :py:meth:`requests.Session.request`
headers: see :py:meth:`requests.Session.request`, content-type defaults to application/json if not set
cookies: see :py:meth:`requests.Session.request`
files: see :py:meth:`requests.Session.request`
auth: see :py:meth:`foundry_dev_tools.clients.context_client.ContextHTTPClient.auth_handler`
timeout: see :py:meth:`requests.Session.request`
allow_redirects: see :py:meth:`requests.Session.request`
proxies: see :py:meth:`requests.Session.request`
hooks: see :py:meth:`requests.Session.request`
stream: see :py:meth:`requests.Session.request`
verify: see :py:meth:`requests.Session.request`
cert: see :py:meth:`requests.Session.request`
json: see :py:meth:`requests.Session.request`
error_handling: error handling config; if set to False, errors won't be automatically handled
"""
if headers:
headers["content-type"] = headers.get("content-type") or headers.get("Content-Type") or "application/json"
else:
headers = {"content-type": "application/json"}
return self.context.client.request(
method=method,
url=self.api_url(api_path),
params=params,
data=data,
headers=headers,
cookies=cookies,
files=files,
auth=auth,
timeout=timeout,
allow_redirects=allow_redirects,
proxies=proxies,
hooks=hooks,
stream=stream,
verify=verify,
cert=cert,
json=json,
error_handling=error_handling,
)
The method
parameter gets passed to the ContextHTTPClient
of the FoundryContext
associated with the APIClient
.
The api_path
parameter builds the correct URL for that API with the build_api_url
method.
@cache
def build_api_url(url: str, api_name: str, api_path: str) -> str:
"""Cached function for building the api URLs."""
return url + "/" + api_name + "/api/" + api_path
This convenience function just appends the Foundry url
to the api_name
of the client and appends the api_path
.
Warning
The ‘Content-Type’ is per default ‘application/json’, if you want another ‘Content-Type’ you need to set it in the headers
parameter.
Naming conventions#
Classes#
The APIClient
implementations should (not a must) be called <Api_name>Client
, omitting “Foundry” from the class name is okay.
For example the API client for “foundry-catalog” is called CatalogClient
instead of FoundryCatalogClient
.
Methods#
Direct API calls#
If it is just a wrapper around an API and does nothing else than calling the api_request
method. The method’s name should begin with ‘api_’ followed by the name of the api.
For example the DataProxyClient.api_get_file
method:
def api_get_file(
self,
dataset_rid: DatasetRid,
transaction_rid: TransactionRid,
logical_path: PathInDataset,
range_header: str | None = None,
requests_stream: bool = True,
**kwargs,
) -> requests.Response:
"""Returns a file from the specified dataset and transaction.
Args:
dataset_rid: dataset rid
transaction_rid: transaction rid
logical_path: path in dataset
range_header: HTTP range header
requests_stream: passed to :py:meth:`requests.Session.request` as `stream`
**kwargs: gets passed to :py:meth:`APIClient.api_request`
"""
return self.api_request(
"GET",
f"dataproxy/datasets/{dataset_rid}/transactions/{transaction_rid}/{quote(logical_path)}",
headers={"Range": range_header} if range_header else None,
stream=requests_stream,
**kwargs,
)
The parameters of the method should be very similar to the original parameters name.
The **kwargs
should be passed to the api_request
method, for custom error handling.
These methods always return the requests.Response
of the API call.
Higher level methods#
Methods which do more than just calling the API like CatalogClient.list_dataset_files
:
def list_dataset_files(
self,
dataset_rid: api_types.DatasetRid,
end_ref: api_types.View = "master",
page_size: int = 1000,
logical_path: api_types.PathInDataset | None = None,
page_start_logical_path: api_types.PathInDataset | None = None,
include_open_exclusive_transaction: bool = False,
exclude_hidden_files: bool = False,
temporary_credentials_auth_token: str | None = None,
) -> list:
"""Same as :py:meth:`CatalogClient.api_get_dataset_view_files3`, but iterates through all pages.
Args:
dataset_rid: the dataset rid
end_ref: branch or transaction rid of the dataset
page_size: the maximum page size returned
logical_path: If logical_path is absent, returns all files in the view.
If logical_path matches a file exactly, returns just that file.
Otherwise, returns all files in the "directory" of logical_path:
(a slash is added to the end of logicalPath if necessary and a prefix-match is performed)
page_start_logical_path: if specified page starts at the given path,
otherwise at the beginning of the file list
include_open_exclusive_transaction: if files added in open transaction should be returned
as well in the response
exclude_hidden_files: if hidden files should be excluded (e.g. _log files)
temporary_credentials_auth_token: to generate temporary credentials for presigned URLs
Returns:
list[FileResourcesPage]:
.. code-block:: python
[
{
"logicalPath": "..",
"pageStartLogicalPath": "..",
"includeOpenExclusiveTransaction": "..",
"excludeHiddenFiles": "..",
},
]
"""
def _inner_get(next_page_token: str | None = None) -> dict:
return self.api_get_dataset_view_files3(
dataset_rid=dataset_rid,
end_ref=end_ref,
page_size=page_size,
logical_path=logical_path,
page_start_logical_path=page_start_logical_path,
include_open_exclusive_transaction=include_open_exclusive_transaction,
exclude_hidden_files=exclude_hidden_files,
start_transaction_rid=next_page_token,
temporary_credentials_auth_token=temporary_credentials_auth_token,
).json()
result: list[dict] = []
batch_result = {"nextPageToken": ""}
while batch_result["nextPageToken"] is not None:
batch_result = _inner_get(next_page_token=batch_result["nextPageToken"])
result.extend(batch_result["values"]) # type: ignore[arg-type]
return result
These methods don’t have specific naming convention and can return anything they want.
API typing#
As you may have seen in the examples above, there are types like DatasetRid
, PathInDataset
, and more
.
These are not “real” types. They are all exactly the same as the str
class. These are encouraged to use, for better readability.
Here dataset
could be anything:
def api_xyz(self, dataset:str): ...
Now we know, it is a path on foundry:
def api_xyz(self,dataset:FoundryPath): ...
Both of these methods can be called via api_xyz("/xyz")
.
You don’t need to convert strings to this type, calling e.g. PathInDataset("/xyz")
is exactly the same as str("/xyz")
, which just returns "/xyz"
again.
Example#
Let’s imagine there is an api called “info”. This does not exist and is completely for illustrative purposes, to show what goes into writing an API client for Foundry with Foundry DevTools.
We start of by creating the class InfoClient
, and set the api_name
attribute to info
.
from __future__ import annotations # this way types don't get 'executed' at runtime
from foundry_dev_tools.clients.api_client import APIClient
from typing import TYPE_CHECKING # special variable that is only `True` if a type checker is 'looking' at the code
from foundry_dev_tools.errors.handling import ErrorHandlingConfig # we will need this later for error handling
from foundry_dev_tools.errors.info import (
UserNotFoundError,
UserAlreadyExistsError,
InsufficientPermissionsError,
) # the errors we will create in the next step
if TYPE_CHECKING:
# will only be imported if type checking, and not at runtime
import requests
class InfoClient(APIClient):
api_name = "info"
Errors#
First of we will define the Exceptions that the API provides, in a seperate file.
UserNotFound#
from foundry_dev_tools.errors.meta import FoundryAPIError
class UserNotFoundError(FoundryAPIError):
message = "The username provided does not exist."
UserAlreadyExists#
class UserNotFoundError(FoundryAPIError):
message = "The username already exists."
InsufficientPermissions#
class InsufficientPermissionsError(FoundryAPIError):
message = "You don't have sufficient permissions to use this API."
API Methods#
Each API method on the info
endpoint, will be its own low level method.
Get User Info#
URL: /user
HTTP Method: GET
Query Parameters:
username: for which user to get information
def api_get_user_info(self, username: str, **kwargs) -> requests.Response:
"""Returns information about the user.
Args:
username: for which user to get information
"""
return self.api_request(
"GET",
"user",
params={"username": username},
error_handling=ErrorHandlingConfig(
{
"Info:UserNotFoundError": UserNotFoundError,
"Info:InsufficientPermissions": InsufficientPermissionsError,
}
),
**kwargs,
)
Change User Info#
URL: /user
HTTP Method: POST
Query Parameters:
username: the name of the user where the information should be changed
Body:
JSON Body with the following definition:
Key Value object, where the key is the information name, and the value is the information value.
Following information names exist:
org: organization name
isAdmin: controls if the user is an admin
enabled: controls if the users account is enabled
If a key is not in the post body it will not be changed.
def api_change_user_info(
self,
username: str,
org: str | None = None,
is_admin: bool | None = None,
enabled: bool | None = None,
**kwargs,
) -> requests.Response:
"""Change user information.
Args:
username: the name of the user where the information should be changed
org: organization name
is_admin: controls if the user is an admin
enabled: controls if the users account is enabled
"""
body = {}
# We only want to change provided values
if org is not None:
body["org"] = org
if is_admin is not None:
body["isAdmin"] = is_admin
if enabled is not None:
body["enabled"] = enabled
return self.api_request(
"POST",
"user",
params={"username": username},
json=body,
error_handling=ErrorHandlingConfig(
{
"Info:UserNotFoundError": UserNotFoundError,
"Info:InsufficientPermissions": InsufficientPermissionsError,
}
),
**kwargs,
)
List users#
URL: /users
HTTP Method: GET
def api_list_users(self, **kwargs) -> requests.Response:
"""List all users."""
return self.api_request(
"GET",
"users",
error_handling=ErrorHandlingConfig({"Info:InsufficientPermissions": InsufficientPermissionsError}),
**kwargs,
)
Create User#
URL: /users
HTTP Method: POST
Body and Query with the same definition as change-user-info
, except that org
is mandatory, and isAdmin
is per default false, and enabled
per default true if omitted.
def api_create_user(
self,
username: str,
org: str,
is_admin: bool | None = None,
enabled: bool | None = None,
**kwargs,
) -> requests.Response:
"""Create a user.
Args:
username: name of the user to be created
org: organization name
is_admin: controls if the user is an admin, defaults to false
enabled: controls if the users account is enabled, defaults to true
"""
body = {"org": org}
# We only want to set provided values
if is_admin is not None:
body["isAdmin"] = is_admin
if enabled is not None:
body["enabled"] = enabled
return self.api_request(
"POST",
"users",
params={"username": username},
json=body,
error_handling=ErrorHandlingConfig(
{
"Info:UserAlreadyExistsError": UserAlreadyExistsError,
"Info:InsufficientPermissions": InsufficientPermissionsError,
}
),
**kwargs,
)