ADSABSClient

CIHex.pmDocsCoverageLicense

A fully-featured, production-ready Elixir client for the SAO/NASA Astrophysics Data System (ADS) API v1.

Features

Installation

def deps do
  [
    {:adsabs_client, "~> 0.2"}
  ]
end

Configuration

# config/config.exs
config :adsabs_client,
  api_token: System.get_env("ADS_API_TOKEN"),
  # Optional overrides (all have sensible defaults):
  base_url: "https://api.adsabs.harvard.edu/v1",
  receive_timeout: 30_000,       # ms
  connect_timeout: 5_000,        # ms
  max_retries: 3,
  base_backoff_ms: 500,
  max_backoff_ms: 30_000,
  rate_limit_warning_threshold: 100

Get your API token at: https://ui.adsabs.harvard.edu/user/settings/token

Quick Start

# Simple string search
{:ok, resp} = ADSABSClient.Search.query("black holes", rows: 10)
resp.num_found          # => 15_432
resp.docs               # => [%{"bibcode" => "...", "title" => [...], ...}, ...]
resp.rate_limit.remaining  # => 4980

# Using the Query builder DSL
alias ADSABSClient.Query

{:ok, resp} =
  Query.new()
  |> Query.author("Hawking, S")
  |> Query.year_range(1970, 2018)
  |> Query.property(:refereed)
  |> Query.fields(["title", "bibcode", "citation_count", "year"])
  |> Query.sort("citation_count", :desc)
  |> Query.rows(20)
  |> ADSABSClient.Search.query()

# Export references to BibTeX
bibcodes = Enum.map(resp.docs, & &1["bibcode"])
{:ok, bibtex} = ADSABSClient.Export.bibtex(bibcodes)

# Metrics
{:ok, metrics} = ADSABSClient.Metrics.fetch(bibcodes)
metrics.indicators["h"]   # h-index
metrics.indicators["g"]   # g-index

# Stream-based lazy pagination (fetches pages on demand)
ADSABSClient.Search.stream("gravitational waves property:refereed")
|> Stream.filter(&((&1["citation_count"] || 0) > 100))
|> Enum.take(500)

API Reference

Search

# String query
{:ok, resp} = ADSABSClient.Search.query("author:Einstein year:1905")

# Find papers citing a bibcode
{:ok, resp} = ADSABSClient.Search.citations(["2016PhRvL.116f1102A"])

# Find references within a paper
{:ok, resp} = ADSABSClient.Search.references(["2016PhRvL.116f1102A"])

# Trending papers related to a query
{:ok, resp} = ADSABSClient.Search.trending("neutron stars")

# Large bibcode set search
{:ok, resp} = ADSABSClient.Search.bigquery(my_bibcode_list, fields: ["title"])

# Infinite lazy stream
stream = ADSABSClient.Search.stream("stellar evolution", rows: 200)

Export

bibcodes = ["2016PhRvL.116f1102A"]

{:ok, bibtex}  = ADSABSClient.Export.bibtex(bibcodes)
{:ok, ris}     = ADSABSClient.Export.ris(bibcodes)
{:ok, endnote} = ADSABSClient.Export.endnote(bibcodes)
{:ok, aastex}  = ADSABSClient.Export.aastex(bibcodes)
{:ok, mnras}   = ADSABSClient.Export.mnras(bibcodes)

# Custom template
{:ok, custom} = ADSABSClient.Export.custom(bibcodes, "%T\n%A\n%Y\n")

Metrics

{:ok, m} = ADSABSClient.Metrics.fetch(bibcodes)
m.indicators["h"]                          # h-index
m.basic_stats["total citations"]           # total citation count
m.citation_stats["average number of citations"]

# Scoped requests
{:ok, _} = ADSABSClient.Metrics.indicators(bibcodes)
{:ok, _} = ADSABSClient.Metrics.citations(bibcodes)
{:ok, _} = ADSABSClient.Metrics.timeseries(bibcodes)

Libraries

{:ok, libs}   = ADSABSClient.Libraries.list()
{:ok, lib}    = ADSABSClient.Libraries.create("My Reading List", description: "Important papers")
{:ok, lib}    = ADSABSClient.Libraries.get(lib.id)
{:ok, _}      = ADSABSClient.Libraries.add_documents(lib.id, bibcodes)
{:ok, _}      = ADSABSClient.Libraries.remove_documents(lib.id, ["old_bibcode"])
{:ok, _}      = ADSABSClient.Libraries.delete(lib.id)

# Permissions
{:ok, _} = ADSABSClient.Libraries.set_permission(lib.id,
  email: "colleague@example.com",
  permission: "read"
)

# Set operations
{:ok, _} = ADSABSClient.Libraries.operation(lib_a_id,
  operation: "union",
  libraries: [lib_b_id],
  name: "Combined Library"
)

Journals

{:ok, summary}  = ADSABSClient.Journals.summary("ApJ")
{:ok, journal}  = ADSABSClient.Journals.journal("A&A")
{:ok, vol}      = ADSABSClient.Journals.volume("ApJ", "900")
{:ok, journal}  = ADSABSClient.Journals.by_issn("0004-637X")
{:ok, holdings} = ADSABSClient.Journals.holdings("Icar")

Resolver

{:ok, links}  = ADSABSClient.Resolver.resolve("2016PhRvL.116f1102A")
{:ok, result} = ADSABSClient.Resolver.resolve("2016PhRvL.116f1102A", :full)
{:ok, url}    = ADSABSClient.Resolver.full_text_url("2016PhRvL.116f1102A")
{:ok, url}    = ADSABSClient.Resolver.preprint_url("2016PhRvL.116f1102A")

Oracle (Recommendations)

{:ok, recs} = ADSABSClient.Oracle.also_read(["2016PhRvL.116f1102A"])

{:ok, matches} = ADSABSClient.Oracle.match_document(
  title: "Observation of Gravitational Waves",
  abstract: "We report the direct detection of gravitational waves...",
  author: ["Abbott, B.P."]
)

Error Handling

All functions return {:ok, result} or {:error, %ADSABSClient.Error{}}:

case ADSABSClient.Search.query("stars") do
  {:ok, resp} ->
    process(resp.docs)

  {:error, %ADSABSClient.Error{type: :rate_limited, retry_after: secs}} ->
    Logger.warning("Rate limited โ€” retry after #{secs}s")
    :timer.sleep(secs * 1_000)
    # retry...

  {:error, %ADSABSClient.Error{type: :unauthorized}} ->
    raise "Invalid API token โ€” check ADS_API_TOKEN env var"

  {:error, %ADSABSClient.Error{type: :network_error, message: msg}} ->
    Logger.error("Network failure: #{msg}")

  {:error, error} ->
    Logger.error("ADS API error [#{error.type}]: #{error.message}")
end

Error types: :unauthorized, :forbidden, :not_found, :rate_limited, :server_error, :network_error, :decode_error, :validation_error

Telemetry

Attach to telemetry events for observability:

# Attach the built-in logger handler
:telemetry.attach_many(
  "adsabs-logger",
  [
    [:adsabs_client, :request, :stop],
    [:adsabs_client, :rate_limit, :warning],
    [:adsabs_client, :rate_limit, :exceeded]
  ],
  &ADSABSClient.Telemetry.log_handler/4,
  nil
)

Events emitted:

Testing

# In test_helper.exs
Mox.defmock(ADSABSClient.HTTP.Mock, for: ADSABSClient.HTTP.Behaviour)
Application.put_env(:adsabs_client, :http_client, ADSABSClient.HTTP.Mock)

# In your test
import Mox

expect(ADSABSClient.HTTP.Mock, :get, fn "/search/query", _opts ->
  {:ok, %{status: 200, headers: [], body: %{"response" => %{"numFound" => 1, "docs" => []}}}}
end)

{:ok, resp} = ADSABSClient.Search.query("stars")
assert resp.num_found == 1

License

Apache 2.0 โ€” see LICENSE.