~cevado/boto

ff16c0f3a786d6c2dfc18b23d462a4a1a45b7219 — cevado 4 months ago 416e4f6 + de629a8 main
Merge pull request 'livebook_example' (#11) from livebook_example into main

Reviewed-on: https://codeberg.org/cevado/boto/pulls/11
2 files changed, 270 insertions(+), 1 deletions(-)

M CHANGELOG.md
A examples/boto_example.livemd
M CHANGELOG.md => CHANGELOG.md +1 -1
@@ 1,4 1,4 @@
# Changelog

## v0.0.1 - 2022-07-04
## v0.0.1 - 2022-09-05
 * First public release 

A examples/boto_example.livemd => examples/boto_example.livemd +269 -0
@@ 0,0 1,269 @@
# Boto Example

```elixir
Mix.install([:boto, :req])
```

## Exemple Implementation

This example include two resolvers.
The first one, `Weather`,  aims to resolve the current temperature for a given ip.
The second one is a `HackerNews` API implementation.

## Weather

```elixir
defmodule Weather do
  @behaviour :boto_resolver

  @impl :boto_resolver
  def resolver_init do
    [
      local_ip: [input: [], output: [:net@ip]],
      ip_location: [input: [:net@ip], output: [:location@lat, :location@long]],
      location_temp: [input: [:location@lat, :location@long], output: [:location@temp]]
    ]
  end

  @impl :boto_resolver
  def resolve(:local_ip, _not_required) do
    resp = Req.get!("https://api.myip.com").body |> Jason.decode!()

    %{net@ip: resp["ip"]}
  end

  def resolve(:ip_location, %{net@ip: ip}) do
    resp = Req.get!("https://get.geojs.io/v1/ip/geo/#{ip}.json").body

    %{location@lat: resp["latitude"], location@long: resp["longitude"]}
  end

  def resolve(:location_temp, %{location@lat: lat, location@long: long}) do
    resp =
      Req.get!(
        "https://api.open-meteo.com/v1/forecast?latitude=#{lat}&longitude=#{long}&current_weather=true",
        compressed: false
      ).body

    temp = resp["current_weather"]["temperature"]
    %{location@temp: "#{temp}°C"}
  end
end
```

So understanding this `Weather` example.

We're using the `open.meteo.com` api to get the current forecast for a given `latitude` and `longitude`. This is done with the `:location_temp` resolver.

Since the idea is to get the forecast for an ip, we're using `geojs.io` to lookup for the location data for a given IP. This is done with the `:ip_location` resolver.

And in case no IP is provided, we use `myip.com` to get the current public ip for the machine running the code. This is done with `:local_ip` resolver.

So `:local_ip` doesn't require any attribute, and outputs a `:net@ip` attribute.
`:ip_location` requires a `:net@ip` to output `:location@lat` and `:location@long` attributes.
At last, `:location_temp`, requires `:location@lat` and `:location@long` to provide a `:location@temp`.

```mermaid
graph TD;
  net+ip-->location+lat;
  net+ip-->location+long;
  location+lat-->location+temp;
  location+long-->location+temp;
```

## HackerNews

```elixir
defmodule HackerNews do
  @behaviour :boto_resolver

  @impl :boto_resolver
  def resolver_init do
    [
      item: [
        input: [:hnitem@id],
        output: [
          :hnitem@deleted,
          :hnitem@type,
          :hnuser@id,
          :hnitem@time,
          :hnitem@text,
          :hnitem@dead,
          {:hnitem@parent, [:hnitem@id]},
          {:hnitem@poll, [:hnitem@id]},
          {:hnitem@kids, [:hnitem@id]},
          :hnitem@url,
          :hnitem@score,
          :hnitem@title,
          {:hnitem@parts, [:hnitem@id]},
          :hnitem@descendants
        ]
      ],
      user: [
        input: [:hnuser@id],
        output: [
          :hnuser@created,
          :hnuser@karma,
          :hnuser@about,
          {:hnuser@submitted, [:hnitem@id]}
        ]
      ],
      top: [input: [:hntop@size], output: [{:hntop@items, [:hnitem@id]}]]
    ]
  end

  @impl :boto_resolver
  def resolve(:item, %{hnitem@id: id}) do
    "https://hacker-news.firebaseio.com/v0/item/#{id}.json"
    |> Req.get!()
    |> Map.get(:body)
    |> Enum.map(&map_item/1)
    |> Enum.into(%{})
  end

  def resolve(:user, %{hnuser@id: id}) do
    user =
      "https://hacker-news.firebaseio.com/v0/user/#{id}.json"
      |> Req.get!()
      |> Map.get(:body)

    submitted = Enum.map(user["submitted"], &wrap_item_id/1)

    %{}
    |> Map.put(:hnuser@created, DateTime.from_unix!(user["created"]))
    |> Map.put(:hnuser@karma, user["karma"])
    |> Map.put(:hnuser@about, user["about"])
    |> Map.put(:hnuser@submitted, submitted)
  end

  def resolve(:top, %{hntop@size: size}) do
    top =
      "https://hacker-news.firebaseio.com/v0/topstories.json"
      |> Req.get!()
      |> Map.get(:body)
      |> Enum.take(size)
      |> Enum.map(&wrap_item_id/1)

    %{hntop@items: top}
  end

  defp wrap_item_id(id), do: %{hnitem@id: id}

  @mapping %{
    "id" => :hnitem@id,
    "deleted" => :hnitem@deleted,
    "type" => :hnitem@type,
    "by" => :hnuser@id,
    "time" => :hnitem@time,
    "text" => :hnitem@text,
    "dead" => :hnitem@dead,
    "parent" => :hnitem@parent,
    "poll" => :hnitem@poll,
    "kids" => :hnitem@kids,
    "url" => :hnitem@url,
    "score" => :hnitem@score,
    "title" => :hnitem@title,
    "parts" => :hnitem@parts,
    "descendants" => :hnitem@descendants
  }
  defp map_item({"kids", ids}), do: {@mapping["kids"], Enum.map(ids, &wrap_item_id/1)}
  defp map_item({"parent", id}), do: {@mapping["kids"], wrap_item_id(id)}
  defp map_item({"poll", id}), do: {@mapping["kids"], wrap_item_id(id)}
  defp map_item({"parts", ids}), do: {@mapping["parts"], Enum.map(ids, &wrap_item_id/1)}
  defp map_item({"time", t}), do: {@mapping["time"], DateTime.from_unix!(t)}
  defp map_item({k, v}), do: {@mapping[k], v}
end
```

So `HackerNews` is a implementation of the [hackernews api](https://github.com/HackerNews/API).
This example is a little bit more complex because it provides nested attributes, so I'll only explain the available resolvers and their required attributes. For more details on the meaing of the data you can check the documentation of the api.

So we start with the `:top` resolver, that requires only the `:hntop@size` attribute, it gonna give back a list of `:hnitem@id`.
We also have `:item` that requires a `:hnitem@id`.
And at last there is `:user` that requires a `:hnuser@id`.

Just to highlight some relations:

```mermaid
graph TD;
hntop+size-->hntop+items;
hntop+items-->hnitem+id;
hnitem+id-->hnuser+id;
hnitem+id-->hnitem+kids;
hnitem+kids-->hnitem+id;
```

## Using Boto

```elixir
:boto_server.start_link(resolvers: [Weather, HackerNews])
```

Here we're starting the server with the resolvers `Weather` and `HackerNews`. This enables `:boto` to query both resolvers to gather data.

Just to be explicit, although those resolvers doesn't relate to each other, they could include data that depends on each other to be queried with no issues.

```elixir
:boto.query(%{}, [:location@temp, :location@lat, :location@long])
```

So to resolve this we resolved the following resolvers:

```mermaid
graph TD;
  local_ip-->ip_location
  ip_location-->location_temp
```

```elixir
:boto.query(%{net@ip: "8.8.8.8"}, [:location@temp, :location@lat, :location@long])
```

And to resolve this one, we passed through the resolvers:

```mermaid
graph TD;
ip_location-->location_temp
```

```elixir
:boto.query(%{hntop@size: 3},
  hntop@items: [
    :hnitem@title,
    :hnitem@score,
    :hnuser@id,
    :hnuser@karma
  ]
)
```

So with this we actually passed through:

```mermaid
graph TD;
top-->item;
item-->user;
```

But it's interesting to note that for all `hntop@items`(in this case 3), it called `item` resolver and `user` resolver to provide all the requested data.

<!-- livebook:{"break_markdown":true} -->

Just a last example to show that the in a single query you can ask for data for both modules implementing the `:boto_resolver` behaviour.

```elixir
:boto.query(
  %{hntop@size: 1, net@ip: "209.216.230.240"},
  [
    :location@temp,
    hntop@items: [
      :hnitem@title,
      :hnitem@score,
      :hnuser@id,
      :hnuser@karma
    ]
  ]
)
```

This last query got just the top item for hacker news and the current temperature for the ip `209.216.230.240` that is the ip for `news.ycombinator.com` per [who.is data](https://who.is/dns/news.ycombinator.com).