Skip to content

Latest commit

 

History

History
313 lines (246 loc) · 6.66 KB

lenses.livemd

File metadata and controls

313 lines (246 loc) · 6.66 KB

Lenses Guide

Mix.install([
  {:lux, "~> 0.4.0"}
  {:kino, "~> 0.14.2"}
])

Application.ensure_all_started([:ex_unit])

Overview

Lenses provide a way to interact with external systems and APIs in a structured, composable way. They handle authentication, data transformation, and error handling for external integrations.

A Lens consists of:

  • A URL endpoint
  • HTTP method and parameters
  • Authentication configuration
  • Schema validation
  • Response transformation

Creating a Lens

Here's a basic example of a Lens:

defmodule MyApp.Lenses.WeatherAPI do
  use Lux.Lens,
    name: "OpenWeather API",
    description: "Fetches weather data from OpenWeather",
    url: "https://api.openweathermap.org/data/2.5/weather",
    method: :get,
    schema: %{
      type: :object,
      properties: %{
        q: %{
          type: :string,
          description: "City name"
        },
        units: %{
          type: :string,
          description: "Temperature units. For temperature in Fahrenheit use units=imperial and for temperature in Celsius use units=metric",
          enum: ["metric", "imperial"]
        },
        appid: %{type: :string, description: "API key"}
      },
      required: ["q", "appid"]
    }

  def after_focus(%{"main" => %{"temp" => temp}} = response) do
    {:ok, %{
      temperature: temp,
      raw_data: response
    }}
  end

  def after_focus(%{"error" => error}) do
    {:error, error}
  end
end

Using Lenses

Lenses can be used directly or within Beams:

{:ok, weather} = MyApp.Lenses.WeatherAPI.focus(%{
  q: "London",
  units: "metric",
  appid: System.fetch_env!("LB_OPEN_WEATHER_API_KEY")
})

frame = Kino.Frame.new() |> Kino.render()

Kino.Frame.append(frame, weather.temperature)
Kino.Frame.append(frame, weather.raw_data)

Kino.nothing()

Authentication Types

Lenses support several authentication methods that are automatically applied when making requests:

API Key Authentication

defmodule MyApp.Lenses.APIKeyAuth do
  use Lux.Lens,
    name: "API Key Example",
    url: "https://api.example.com/data",
    auth: %{
      type: :api_key,
      key: System.get_env("API_KEY")
    }
end

When using API key authentication, the key is automatically added as a Bearer token in the Authorization header.

Basic Authentication

defmodule MyApp.Lenses.BasicAuth do
  use Lux.Lens,
    name: "Basic Auth Example",
    url: "https://api.example.com/secure",
    auth: %{
      type: :basic,
      username: System.get_env("API_USER"),
      password: System.get_env("API_PASS")
    }
end

Basic authentication automatically encodes the username and password in Base64 format and adds them to the Authorization header.

OAuth Authentication

defmodule MyApp.Lenses.OAuthExample do
  use Lux.Lens,
    name: "OAuth Example",
    url: "https://api.example.com/oauth",
    auth: %{
      type: :oauth,
      token: System.get_env("OAUTH_TOKEN")
    }
end

OAuth authentication adds the token as a Bearer token in the Authorization header.

Custom Authentication

defmodule MyApp.Lenses.CustomAuth do
  use Lux.Lens,
    name: "Custom Auth Example",
    url: "https://api.example.com/custom",
    auth: %{
      type: :custom,
      auth_function: &__MODULE__.authenticate/1
    }

  def authenticate(lens) do
    # Add custom headers or modify request
    headers = [{"X-Custom-Auth", "value"}]
    %{lens | headers: headers}
  end
end

Custom authentication allows you to implement your own authentication logic by providing a function that modifies the lens before the request is made.

Response Transformation

Basic Transformation

defmodule MyApp.Lenses.UserAPI do
  use Lux.Lens,
    name: "User API",
    url: "https://api.example.com/users"

  def after_focus(%{"data" => users}) do
    transformed =
      Enum.map(users, fn user ->
        %{
          id: user["id"],
          name: user["name"],
          email: user["email"]
        }
      end)

    {:ok, %{users: transformed}}
  end
end

Error Handling

defmodule MyApp.Lenses.RobustAPI do
  use Lux.Lens,
    name: "Robust API",
    url: "https://api.example.com/data"

  def after_focus(%{"error" => error}) do
    {:error, "API Error: #{error}"}
  end

  def after_focus(%{"data" => nil}) do
    {:error, "No data available"}
  end

  def after_focus(%{"data" => data}) do
    {:ok, data}
  end

  def after_focus(response) do
    {:error, "Unexpected response format: #{inspect(response)}"}
  end
end

Best Practices

  1. Authentication

    • Use environment variables for credentials
    • Keep sensitive data out of version control
    • Use appropriate auth type for the API
    • Handle authentication errors gracefully
  2. Error Handling

    • Handle common HTTP errors
    • Transform API-specific errors
    • Provide meaningful error messages
    • Include request context in errors
  3. Response Transformation

    • Clean and normalize data
    • Remove unnecessary fields
    • Convert types appropriately
    • Handle missing or null values
  4. Testing

    • Mock HTTP requests
    • Test authentication
    • Test error cases
    • Test transformations

Example test:

defmodule MyApp.Lenses.WeatherAPITest do
  use UnitCase, async: true

  setup do
    Req.Test.verify_on_exit!()
    :ok
  end

  describe "focus/1" do
    test "fetches weather data successfully" do
      Req.Test.stub(Lux.Lens, fn conn ->
        assert conn.params == %{"q" => "London", "units" => "metric", "appid" => "test_key"}

        Req.Test.json(conn, %{
          "main" => %{"temp" => 20.5},
          "weather" => [%{"description" => "clear sky"}]
        })
      end)

      {:ok, result} = MyApp.Lenses.WeatherAPI.focus(%{
        q: "London",
        units: "metric",
        appid: System.fetch_env!("LB_OPEN_WEATHER_API_KEY")
      })

      assert is_float(result.temperature)
    end

    test "handles API errors" do
      Req.Test.stub(Lux.Lens, fn conn ->
        Req.Test.json(conn, %{"error" => "City not found"})
      end)

      assert {:error, _} = MyApp.Lenses.WeatherAPI.focus(%{
        q: "NonexistentCity",
        units: "metric",
        appid: System.fetch_env!("LB_OPEN_WEATHER_API_KEY")
      })
    end
  end
end

ExUnit.run()

Advanced Topics

Retry Logic

defmodule MyApp.Lenses.RetryingAPI do
  use Lux.Lens,
    name: "Retrying API",
    url: "https://api.example.com/data",
    retry: %{
      max_attempts: 3,
      base_delay: 1000,
      max_delay: 5000,
      exponential: true
    }

  def should_retry?({:error, %{status: status}}) do
    status in [500, 502, 503, 504]
  end
  def should_retry?(_), do: false

  def after_focus(response) do
    {:ok, response}
  end
end