Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

OData REST Client

odata_poc.livemd

OData REST Client

Introduction

This Livebook provides an interface to interact with OData services using a dynamically generated client based on a Postman collection. The client adapts to the structure of the Postman collection, automatically generating functions for each request defined in the collection.

Setup

First, let’s set up our dependencies. We’ll use:

  • jason for JSON parsing
  • tesla for HTTP requests
  • req as an alternative HTTP client
Mix.install([
  {:jason, "~> 1.4"},
  {:tesla, "~> 1.4"},
  {:hackney, "~> 1.18"},
  {:req, "~> 0.3.0"}
])

OData Client Implementation

Let’s define our OData client module that will parse the Postman collection and generate the appropriate functions.

defmodule ODataClient do
  @moduledoc """
  A dynamic OData client that generates functions based on a Postman collection.
  """

  # Define a struct to hold the client configuration
  defstruct [:collection, :base_url, :functions]

  @doc """
  Initialize a new OData client from a Postman collection file.

  ## Parameters

  - `file_path`: Path to the Postman collection JSON file
  - `options`: Additional options for the client
    - `:base_url`: Override the base URL from the collection

  ## Returns

  A new ODataClient struct with the parsed collection and generated functions.
  """
  def new(file_path, options \\ []) do
    collection = load_collection(file_path)
    base_url = Keyword.get(options, :base_url)
    functions = generate_functions(collection)

    %__MODULE__{
      collection: collection,
      base_url: base_url,
      functions: functions
    }
  end

  @doc """
  Load and parse a Postman collection from a file or URL.
  """
  def load_collection(path_or_url) do
    content =
      if String.starts_with?(path_or_url, ["http://", "https://"]) do
        # If it's a URL, fetch it using Req
        response = Req.get!(path_or_url)
        response.body
      else
        # If it's a file path, read it from the filesystem
        File.read!(path_or_url)
      end

    Jason.decode!(content)
  end

  @doc """
  Generate function metadata from the Postman collection.
  """
  def generate_functions(collection) do
    collection["item"]
    |> Enum.map(fn item ->
      function_name = generate_function_name(item["name"])
      method = get_method(item)
      url = get_url(item)
      params = extract_params(url, item)
      body = get_body(item)

      %{
        name: function_name,
        original_name: item["name"],
        method: method,
        url: url,
        params: params,
        body: body,
        description: item["request"]["description"]
      }
    end)
  end

  @doc """
  Generate a function name from a request name.
  """
  def generate_function_name(name) do
    name
    |> String.downcase()
    |> String.replace(~r/^\d+\.\s+/, "")  # Remove leading numbers and dots
    |> String.replace(~r/[^a-z0-9\s]/u, " ")  # Replace non-alphanumeric with spaces
    |> String.split()
    |> Enum.join("_")
    |> String.to_atom()
  end

  @doc """
  Get the HTTP method from a request item.
  """
  def get_method(item) do
    item["request"]["method"]
    |> String.downcase()
    |> String.to_atom()
  end

  @doc """
  Get the URL from a request item.
  """
  def get_url(item) do
    case item["request"]["url"] do
      url when is_binary(url) -> url
      url when is_map(url) -> url["raw"]
    end
  end

  @doc """
  Extract parameters from a URL and request.
  """
  def extract_params(url, item) do
    # Extract path parameters (e.g., People('russellwhyte'))
    path_params = Regex.scan(~r/'([^']+)'/, url)
                  |> Enum.map(fn [_, param] -> param end)

    # Extract query parameters if available
    query_params = case item["request"]["url"] do
      url when is_map(url) ->
        # Check if "query" exists and is a list inside the clause body
        case url["query"] do
          query_list when is_list(query_list) ->
            query_list
            |> Enum.map(fn param -> param["key"] end)
          _ -> # Handle cases where "query" is missing or not a list
            []
        end
      _ -> # Handle cases where item["request"]["url"] is not a map
        []
    end

    # Combine all parameters
    path_params ++ query_params
  end

  @doc """
  Get the request body if available.
  """
  def get_body(item) do
    case get_in(item, ["request", "body", "raw"]) do
      nil -> nil
      raw_body ->
        try do
          Jason.decode!(raw_body)
        rescue
          _ -> raw_body
        end
    end
  end

  @doc """
  Execute a request using the specified function name and parameters.
  """
  def execute(client, function_name, params \\ %{}, body \\ nil) do
    function = Enum.find(client.functions, fn f -> f.name == function_name end)

    unless function do
      raise "Function #{function_name} not found in the OData client"
    end

    # Prepare the URL with parameters
    url = prepare_url(function.url, params)

    # Prepare the request body
    request_body = body || function.body

    # Execute the request
    case function.method do
      :get -> Req.get!(url)
      :post -> Req.post!(url, json: request_body)
      :put -> Req.put!(url, json: request_body)
      :patch -> Req.patch!(url, json: request_body)
      :delete -> Req.delete!(url)
    end
  end

  @doc """
  Prepare a URL by replacing parameters with their values.
  """
  def prepare_url(url, params) do
    # Replace path parameters
    url = Regex.replace(~r/'([^']+)'/, url, fn _, param ->
      case Map.get(params, String.to_atom(param)) || Map.get(params, param) do
        nil -> "'#{param}'"  # Keep original if not provided
        value -> "'#{value}'"
      end
    end)

    # Add query parameters, but only if they don't already exist in the URL
    query_params = for {key, value} <- params,
                      is_binary(key) &amp;&amp; String.starts_with?(key, "$"),
                      # Skip if the parameter already exists in the URL
                      not String.contains?(url, "#{key}="),
                      do: "#{key}=#{URI.encode_www_form(value)}"

    if Enum.empty?(query_params) do
      url
    else
      if String.contains?(url, "?") do
        "#{url}&#{Enum.join(query_params, "&")}"
      else
        "#{url}?#{Enum.join(query_params, "&")}"
      end
    end
  end

  @doc """
  List all available functions in the client.
  """
  def list_functions(client) do
    client.functions
    |> Enum.map(fn function ->
      %{
        name: function.name,
        original_name: function.original_name,
        method: function.method,
        url: function.url,
        params: function.params
      }
    end)
  end

  @doc """
  Get detailed information about a specific function.
  """
  def get_function_info(client, function_name) do
    Enum.find(client.functions, fn f -> f.name == function_name end)
  end
end

Using the OData Client

Now let’s initialize our client with the Postman collection:

# Use the raw GitHub URL for the Postman collection
collection_path = "https://raw.githubusercontent.com/zebbra/tt_odata_poc/main/postman_collection_sap_odata.json"

# Initialize the client
client = ODataClient.new(collection_path)

# List all available functions
ODataClient.list_functions(client)

Example Usage

Now that we have initialized the client, let’s try some example requests:

# Read the service root
ODataClient.execute(client, :read_the_service_root)
# Get a list of people
ODataClient.execute(client, :read_an_entity_set)
# Get a specific person
ODataClient.execute(client, :get_a_single_entity_from_an_entity_set, %{"russellwhyte" => "russellwhyte"})
# Filter people by first name
ODataClient.execute(client, :read_an_entity_set, %{"$filter" => "FirstName eq 'Scott'"})

Creating a New Entity

# Create a new person
new_person = %{
  "UserName" => "johndoe",
  "FirstName" => "John",
  "LastName" => "Doe",
  "Gender" => "Male"
}

ODataClient.execute(client, :create_an_entity, %{}, new_person)

Updating an Entity

# Update a person's email
update_data = %{
  "Emails" => ["johndoe@contoso.com", "johndoe@example.com"]
}

# Note: In a real scenario, you would need to include the If-Match header with the ETag
ODataClient.execute(client, :update_an_entity, %{"miathompson" => "johndoe"}, update_data)

Deleting an Entity

# Delete a person
# Note: In a real scenario, you would need to include the If-Match header with the ETag
ODataClient.execute(client, :delete_an_entity, %{"miathompson" => "johndoe"})

Advanced Usage: Custom Headers and Authentication

For scenarios requiring custom headers (like authentication or ETags for concurrency control), we can extend our client:

defmodule ODataClientExtended do
  @doc """
  Execute a request with custom headers.
  """
  def execute_with_headers(client, function_name, headers, params \\ %{}, body \\ nil) do
    function = Enum.find(client.functions, fn f -> f.name == function_name end)

    unless function do
      raise "Function #{function_name} not found in the OData client"
    end

    # Prepare the URL with parameters
    url = ODataClient.prepare_url(function.url, params)

    # Prepare the request body
    request_body = body || function.body

    # Execute the request with custom headers
    case function.method do
      :get -> Req.get!(url, headers: headers)
      :post -> Req.post!(url, json: request_body, headers: headers)
      :put -> Req.put!(url, json: request_body, headers: headers)
      :patch -> Req.patch!(url, json: request_body, headers: headers)
      :delete -> Req.delete!(url, headers: headers)
    end
  end
end

Example with custom headers:

# Update with concurrency control
headers = [
  {"If-Match", "W/\"08D2931BACB7D7FD\""},
  {"Content-Type", "application/json"}
]

update_data = %{
  "Emails" => ["johndoe@contoso.com", "johndoe@example.com"]
}

ODataClientExtended.execute_with_headers(
  client,
  :update_an_entity,
  headers,
  %{"miathompson" => "johndoe"},
  update_data
)

Conclusion

This Livebook demonstrates a dynamic OData client that adapts to the structure of a Postman collection. The client automatically generates functions for each request defined in the collection, making it easy to interact with OData services.

Key features:

  • Dynamically generates functions based on a Postman collection
  • Supports all HTTP methods (GET, POST, PUT, PATCH, DELETE)
  • Handles path and query parameters
  • Supports request bodies for POST, PUT, and PATCH requests
  • Can be extended with custom headers for authentication and concurrency control

If you update the Postman collection, simply reinitialize the client to get the updated functions.