Skip to main content

README.md

# rocksky — Gleam SDK for the Rocksky XRPC API

[![Package Version](https://img.shields.io/hexpm/v/rocksky)](https://hex.pm/packages/rocksky)
[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/rocksky)

A pipe-friendly, batteries-included Gleam client for the
[Rocksky](https://rocksky.app) XRPC API, generated from the lexicons in
[`apps/api/lexicons`](../../apps/api/lexicons).

```sh
gleam add rocksky
```

## At a glance

Every endpoint returns a `rocksky.Request(a)`. Refine it with chainable
helpers, then `send` it through a `Client`:

```gleam
import gleam/io
import gleam/option
import rocksky
import rocksky/actor
import rocksky/scrobble

pub fn main() {
  let client =
    rocksky.new()
    |> rocksky.with_bearer_token("xxx")

  // GET app.rocksky.actor.getProfile
  let assert Ok(profile) =
    actor.get_profile(did: "did:plc:7vdlgi2bflelz7mmuxoqjfcr")
    |> rocksky.send(client)
  io.println("Hello, " <> option.unwrap(profile.handle, "unknown") <> "!")

  // Optional params chain naturally — no Some/None at the call site.
  let assert Ok(scrobbles) =
    actor.get_actor_scrobbles(did: "did:plc:7vdlgi2bflelz7mmuxoqjfcr")
    |> rocksky.limit(50)
    |> rocksky.offset(0)
    |> rocksky.send(client)

  // Procedures with rich bodies use a typed builder, ending in `create`:
  let _ =
    scrobble.new_scrobble(title: "Karma Police", artist: "Radiohead")
    |> scrobble.with_album("OK Computer")
    |> scrobble.with_duration_ms(263_000)
    |> scrobble.create
    |> rocksky.send(client)
}
```

## Design

- **One `Client`, one pipe.** Build the client once, then every call reads as
  `endpoint(...) |> rocksky.<param>(...) |> ... |> rocksky.send(client)`.
- **No `Option(_)` at the call site.** Required params land in the endpoint
  constructor; optional params are added by chaining functions on the
  `Request(a)` value. Skip them by simply not piping them.
- **Builders for body-heavy procedures.** `scrobble.create`, `song.create`,
  `shout.create`, etc. accept a typed builder (`NewScrobble`, `NewSong`) that
  flows through `with_*` setters before becoming a `Request`.
- **`Request(a)` is just data.** It carries the method, params, headers, body
  and decoder. You can pass it around, decorate it (`rocksky.header` for
  per-request headers), and only at `send` does the network happen.
- **Errors are explicit.** `RocksyError` distinguishes transport errors,
  XRPC errors, raw HTTP failures, and decode failures.
- **Swappable transport.** `rocksky.with_send` lets you plug in your own
  HTTP function (great for tests; required if you target JavaScript).

## Configuration

```gleam
let client =
  rocksky.new()
  |> rocksky.with_base_url("https://api.rocksky.app")   // default
  |> rocksky.with_bearer_token("xxx")
  |> rocksky.with_user_agent("my-app/1.0")
  |> rocksky.with_header("x-trace-id", "abc123")
```

## Builder vocabulary

These chainable helpers live in the `rocksky` module and work on any
`Request(a)`:

| Function                            | XRPC parameter      |
| ----------------------------------- | ------------------- |
| `rocksky.limit(n)`                  | `limit`             |
| `rocksky.offset(n)`                 | `offset`            |
| `rocksky.cursor(c)`                 | `cursor`            |
| `rocksky.start_date(d)`             | `startDate`         |
| `rocksky.end_date(d)`               | `endDate`           |
| `rocksky.genre(g)`                  | `genre`             |
| `rocksky.year(y)`                   | `year`              |
| `rocksky.size(n)`                   | `size`              |
| `rocksky.param(name, value)`        | arbitrary string    |
| `rocksky.int_param(name, value)`    | arbitrary int       |
| `rocksky.bool_param(name, value)`   | arbitrary bool      |
| `rocksky.repeated_param(name, vs)`  | array (repeats key) |
| `rocksky.header(name, value)`       | per-request header  |

Namespace-specific params (e.g. `charts.with_artist_uri`, `graph.with_dids`,
`player.with_player_id`) live in their own module so the global vocabulary
stays small.

## Endpoint modules

The SDK mirrors the lexicon namespaces. Each module hosts the queries and
procedures under `app.rocksky.<namespace>`:

| Module               | Lexicon namespace            |
| -------------------- | ---------------------------- |
| `rocksky/actor`      | `app.rocksky.actor.*`        |
| `rocksky/album`      | `app.rocksky.album.*`        |
| `rocksky/apikey`     | `app.rocksky.apikey.*`       |
| `rocksky/artist`     | `app.rocksky.artist.*`       |
| `rocksky/charts`     | `app.rocksky.charts.*`       |
| `rocksky/dropbox`    | `app.rocksky.dropbox.*`      |
| `rocksky/feed`       | `app.rocksky.feed.*`         |
| `rocksky/googledrive`| `app.rocksky.googledrive.*`  |
| `rocksky/graph`      | `app.rocksky.graph.*`        |
| `rocksky/like`       | `app.rocksky.like.*`         |
| `rocksky/mirror`     | `app.rocksky.mirror.*`       |
| `rocksky/player`     | `app.rocksky.player.*`       |
| `rocksky/playlist`   | `app.rocksky.playlist.*`     |
| `rocksky/scrobble`   | `app.rocksky.scrobble.*`     |
| `rocksky/shout`      | `app.rocksky.shout.*`        |
| `rocksky/song`       | `app.rocksky.song.*`         |
| `rocksky/spotify`    | `app.rocksky.spotify.*`      |
| `rocksky/stats`      | `app.rocksky.stats.*`        |

## Decoding `Dynamic` responses

Common views (`Profile`, `Artist`, `Album`, `Song`, `Scrobble`, `Stats`,
`Listener`, `Shout`, `ApiKey`) are typed in `rocksky/types`. For inline,
anonymous JSON objects (e.g. feed search, chart shapes) the SDK types the
response as `Dynamic` so you can decode it on your terms:

```gleam
import gleam/dynamic/decode
import rocksky
import rocksky/decoders
import rocksky/feed

let assert Ok(payload) =
  feed.search(q: "radiohead") |> rocksky.send(client)

let result_decoder = {
  use artists <- decode.optional_field("artists", [], decode.list(decoders.artist()))
  use songs <- decode.optional_field("songs", [], decode.list(decoders.song()))
  decode.success(#(artists, songs))
}

let assert Ok(#(artists, songs)) = decode.run(payload, result_decoder)
```

## Error handling

```gleam
import rocksky/error

let result =
  actor.get_profile(did: "garbage") |> rocksky.send(client)

case result {
  Ok(p) -> // ...
  Error(error.XrpcError(status: _, name: "InvalidRequest", message: m)) ->
    // Server told us why
  Error(error.TransportError(_)) ->
    // DNS, TLS, etc.
  Error(error.HttpStatusError(status: _, body: _)) ->
    // Non-XRPC 4xx/5xx
  Error(error.DecodeError(_)) ->
    // Server returned JSON we didn't expect
  Error(error.InvalidInput(_)) ->
    // Caught client-side before sending
}
```

## Reaching un-surfaced XRPC methods

If the SDK is missing an endpoint, drop down to the underlying constructors:

```gleam
import gleam/dynamic/decode

let _ =
  rocksky.query("app.rocksky.some.newEndpoint", decode.dynamic)
  |> rocksky.param("foo", "bar")
  |> rocksky.send(client)
```

## Examples

Runnable examples live under [`src/examples/`](./src/examples) so they are
compile-checked against the SDK on every `gleam build`. Run any of them with
`gleam run -m examples/<name>`:

- [`profile.gleam`](./src/examples/profile.gleam) — fetch a profile and print it
- [`scrobble.gleam`](./src/examples/scrobble.gleam) — record a play (builder)
- [`paginate_scrobbles.gleam`](./src/examples/paginate_scrobbles.gleam) — walk
  a user's scrobble history in pages
- [`search.gleam`](./src/examples/search.gleam) — search and decode results
- [`wrapped.gleam`](./src/examples/wrapped.gleam) — fetch year-in-review stats
- [`custom_transport.gleam`](./src/examples/custom_transport.gleam) — swap
  the HTTP backend (for tests / JS targets)

## Testing

```sh
gleam test
```

The SDK is built around an injectable transport, so unit tests don't need a
network. See `test/rocksky/client_test.gleam` for the mock-send pattern.

## License

[MIT](LICENSE) © Tsiry Sandratraina.