Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/pull-request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,23 @@ jobs:
run: cargo test
env:
DUNE_API_KEY: ${{ secrets.DUNE_API_KEY }}

doc-coverage:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- name: Install Rust nightly
uses: dtolnay/rust-toolchain@nightly
with:
components: rust-docs
- name: Cache Cargo dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}

- name: Doc coverage check
run: bash scripts/check-doc-coverage.sh
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "duners"
version = "0.0.3"
version = "0.0.7"
authors = ["Ben Smith <bh2smith@gmail.com>"]
edition = "2021"
description = "A simple framework for fetching query results from with [Dune Analytics API](https://dune.com/docs/api/)."
Expand Down
68 changes: 68 additions & 0 deletions scripts/check-doc-coverage.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Ensures doc coverage is at or above the baseline (documented %, examples %).
# Update scripts/doc-coverage-baseline.txt when you intentionally improve coverage.
#
# Test locally (requires nightly: rustup toolchain install nightly):
# bash scripts/check-doc-coverage.sh
#
# Test parsing only (skip running cargo):
# DOC_COVERAGE_OUTPUT=scripts/fixtures/doc-coverage-sample.txt bash scripts/check-doc-coverage.sh # should pass
# DOC_COVERAGE_OUTPUT=scripts/fixtures/doc-coverage-below-baseline.txt bash scripts/check-doc-coverage.sh # should fail

set -euo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"

BASELINE_FILE="$REPO_ROOT/scripts/doc-coverage-baseline.txt"
if [[ ! -f "$BASELINE_FILE" ]]; then
echo "Missing baseline file: $BASELINE_FILE"
exit 1
fi

read -r MIN_DOC MIN_EX < "$BASELINE_FILE"

if [[ -n "${DOC_COVERAGE_OUTPUT:-}" ]]; then
echo "Using existing coverage output: $DOC_COVERAGE_OUTPUT"
COVERAGE_OUTPUT="$DOC_COVERAGE_OUTPUT"
else
echo "Running rustdoc coverage (nightly)..."
COVERAGE_OUTPUT=$(mktemp)
trap 'rm -f "$COVERAGE_OUTPUT"' EXIT
cargo +nightly rustdoc -Z unstable-options -- -Z unstable-options --show-coverage 2>&1 | tee "$COVERAGE_OUTPUT"
fi

TOTAL_LINE=$(grep '| Total ' "$COVERAGE_OUTPUT" || true)
if [[ -z "$TOTAL_LINE" ]]; then
echo "Could not find '| Total' in rustdoc output"
exit 1
fi

# Parse documented % and examples % from the table (columns 4 and 6 after splitting on |)
CURRENT_DOC=$(echo "$TOTAL_LINE" | awk -F'|' '{ gsub(/[ %]/,"",$4); print $4 }')
CURRENT_EX=$(echo "$TOTAL_LINE" | awk -F'|' '{ gsub(/[ %]/,"",$6); print $6 }')

echo ""
echo "Baseline: documented >= $MIN_DOC%, examples >= $MIN_EX%"
echo "Current: documented = ${CURRENT_DOC}%, examples = ${CURRENT_EX}%"

FAIL=0
if awk -v a="$CURRENT_DOC" -v b="$MIN_DOC" 'BEGIN{exit (a+0>=b+0)?0:1}'; then
:
else
echo "FAIL: documented coverage decreased (${CURRENT_DOC}% < ${MIN_DOC}%)"
FAIL=1
fi
if awk -v a="$CURRENT_EX" -v b="$MIN_EX" 'BEGIN{exit (a+0>=b+0)?0:1}'; then
:
else
echo "FAIL: examples coverage decreased (${CURRENT_EX}% < ${MIN_EX}%)"
FAIL=1
fi

if [[ $FAIL -eq 1 ]]; then
echo "Update scripts/doc-coverage-baseline.txt if this decrease is intentional."
exit 1
fi

echo "Doc coverage check passed."
1 change: 1 addition & 0 deletions scripts/doc-coverage-baseline.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
96.1 35.5
10 changes: 10 additions & 0 deletions scripts/fixtures/doc-coverage-sample.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Documenting duners v0.0.7
+-------------------------------------+------------+------------+------------+------------+
| File | Documented | Percentage | Examples | Percentage |
+-------------------------------------+------------+------------+------------+------------+
| src/lib.rs | 1 | 100.0% | 1 | 100.0% |
| src/response.rs | 47 | 100.0% | 2 | 20.0% |
+-------------------------------------+------------+------------+------------+------------+
| Total | 74 | 96.1% | 11 | 35.5% |
+-------------------------------------+------------+------------+------------+------------+
Finished `dev` profile [unoptimized] target(s) in 0.12s
33 changes: 33 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
//! Dune API client implementation.
//!
//! This module provides [`DuneClient`] for calling the [Dune Analytics API](https://dune.com/docs/api/).

use crate::error::{DuneError, DuneRequestError};
use crate::parameters::Parameter;
use crate::response::{
Expand All @@ -12,6 +16,7 @@ use std::collections::HashMap;
use std::env;
use tokio::time::{sleep, Duration};

/// Base URL for the Dune API (v1).
const BASE_URL: &str = "https://api.dune.com/api/v1";

/// Client for the [Dune Analytics API](https://dune.com/docs/api/).
Expand Down Expand Up @@ -104,6 +109,18 @@ impl DuneClient {

/// Execute Query (with or without parameters)
/// cf. [https://dune.com/docs/api/api-reference/execute-queries/execute-query-id/](https://dune.com/docs/api/api-reference/execute-queries/execute-query-id/)
///
/// # Example
///
/// ```no_run
/// use duners::{DuneClient, DuneRequestError};
///
/// # async fn run() -> Result<(), DuneRequestError> {
/// let client = DuneClient::from_env();
/// let exec = client.execute_query(971694, None).await?;
/// println!("Execution ID: {}", exec.execution_id);
/// # Ok(()) }
/// ```
pub async fn execute_query(
&self,
query_id: u32,
Expand Down Expand Up @@ -141,6 +158,22 @@ impl DuneClient {

/// Get Query Execution Results (by `job_id`)
/// cf. [https://dune.com/docs/api/api-reference/get-results/execution-results/](https://dune.com/docs/api/api-reference/get-results/execution-results/)
///
/// # Example
///
/// ```no_run
/// use duners::{DuneClient, DuneRequestError};
/// use serde::Deserialize;
///
/// #[derive(Deserialize, Debug)]
/// struct Row { symbol: String, max_price: f64 }
///
/// # async fn run() -> Result<(), DuneRequestError> {
/// let client = DuneClient::from_env();
/// let results = client.get_results::<Row>("your-execution-id").await?;
/// for row in results.get_rows() { println!("{:?}", row); }
/// # Ok(()) }
/// ```
pub async fn get_results<T: DeserializeOwned>(
&self,
job_id: &str,
Expand Down
12 changes: 12 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//! Error types for Dune API requests and response parsing.

use serde::Deserialize;
use std::fmt;

Expand All @@ -12,6 +14,16 @@ pub struct DuneError {
///
/// Use `?` in async functions that return `Result<_, DuneRequestError>` to propagate errors.
/// Implements [`std::error::Error`] and [`Display`](fmt::Display) for logging and error reporting.
///
/// # Example
///
/// ```rust
/// use duners::DuneRequestError;
///
/// fn handle_error(e: DuneRequestError) {
/// eprintln!("{}", e);
/// }
/// ```
#[derive(Debug, PartialEq)]
pub enum DuneRequestError {
/// Error returned by the Dune API. Common messages include:
Expand Down
14 changes: 14 additions & 0 deletions src/parameters.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
//! Query parameters for parameterized Dune queries.
//!
//! Use [`Parameter`] and its constructors ([`Parameter::text`], [`Parameter::number`], etc.)
//! when calling [`execute_query`](crate::client::DuneClient::execute_query) or [`refresh`](crate::client::DuneClient::refresh).

use chrono::{DateTime, Utc};

/// Dune supports four parameter types; all are sent to the API as JSON strings.
Expand Down Expand Up @@ -39,6 +44,15 @@ pub struct Parameter {

impl Parameter {
/// Builds a **date** parameter. The value is sent as `YYYY-MM-DD HH:MM:SS`.
///
/// # Example
///
/// ```rust
/// use duners::Parameter;
/// use chrono::Utc;
///
/// let p = Parameter::date("StartDate", Utc::now());
/// ```
pub fn date(name: &str, value: DateTime<Utc>) -> Self {
Parameter {
key: String::from(name),
Expand Down
18 changes: 16 additions & 2 deletions src/parse_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ fn date_string_parser(date_str: &str, format: &str) -> Result<DateTime<Utc>, Par
/// Parses API metadata date strings (e.g. `submitted_at`, `execution_ended_at`).
///
/// Format: `%Y-%m-%dT%H:%M:%S.%fZ` (ISO 8601 with optional subseconds).
///
/// # Example
///
/// ```rust
/// use duners::parse_utils::date_parse;
///
/// let dt = date_parse("2022-01-01T12:00:00.000Z").unwrap();
/// assert_eq!(dt.format("%Y-%m-%d").to_string(), "2022-01-01");
/// ```
pub fn date_parse(date_str: &str) -> Result<DateTime<Utc>, ParseError> {
date_string_parser(date_str, "%Y-%m-%dT%H:%M:%S.%fZ")
}
Expand Down Expand Up @@ -81,12 +90,17 @@ where
///
/// # Example
///
/// ```ignore
/// ```rust
/// use duners::parse_utils::f64_from_str;
/// use serde::Deserialize;
///
/// #[derive(Deserialize)]
/// struct MyRow {
/// #[serde(deserialize_with = "duners::parse_utils::f64_from_str")]
/// #[serde(deserialize_with = "f64_from_str")]
/// price: f64,
/// }
///
/// // In real usage, MyRow is deserialized from Dune API JSON.
/// ```
pub fn f64_from_str<'de, D>(deserializer: D) -> Result<f64, D::Error>
where
Expand Down
56 changes: 54 additions & 2 deletions src/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ use std::str::FromStr;
/// Returned from [`DuneClient::execute_query`](crate::client::DuneClient::execute_query). Contains the execution ID to poll or fetch results.
#[derive(Deserialize, Debug)]
pub struct ExecutionResponse {
/// Use this ID with [`get_status`](crate::client::DuneClient::get_status) and [`get_results`](crate::client::DuneClient::get_results).
pub execution_id: String,
/// Current state of the execution (e.g. [`ExecutionStatus::Pending`]).
pub state: ExecutionStatus,
}

Expand All @@ -23,10 +25,15 @@ pub struct ExecutionResponse {
/// Pending state also comes along with a "queue position"
#[derive(DeserializeFromStr, Debug, PartialEq)]
pub enum ExecutionStatus {
/// Query finished successfully; results are available.
Complete,
/// Query is currently running.
Executing,
/// Query is queued; check `queue_position` on [`GetStatusResponse`].
Pending,
/// Execution was cancelled (e.g. via [`cancel_execution`](crate::client::DuneClient::cancel_execution)).
Cancelled,
/// Execution failed (e.g. timeout after 30 minutes).
Failed,
}

Expand All @@ -46,8 +53,16 @@ impl FromStr for ExecutionStatus {
}

impl ExecutionStatus {
/// utility method for terminal query execution status.
/// The three terminal states are complete, cancelled and failed.
/// Returns `true` when execution will not change state again (complete, cancelled, or failed).
///
/// # Example
///
/// ```rust
/// use duners::ExecutionStatus;
///
/// assert!(ExecutionStatus::Complete.is_terminal());
/// assert!(!ExecutionStatus::Pending.is_terminal());
/// ```
pub fn is_terminal(&self) -> bool {
match self {
ExecutionStatus::Complete => true,
Expand All @@ -71,17 +86,26 @@ pub struct CancellationResponse {
/// and always contained in [ExecutionResult](ExecutionResult).
#[derive(Deserialize, Debug)]
pub struct ResultMetaData {
/// Names of columns in the result set.
pub column_names: Vec<String>,
/// Optional Dune type names for each column.
#[serde(default)]
pub column_types: Option<Vec<String>>,
/// Number of rows in this result set (when present).
#[serde(default)]
pub row_count: Option<u32>,
/// Size in bytes of the result set.
pub result_set_bytes: u64,
/// Total size when result is paged.
#[serde(default)]
pub total_result_set_bytes: Option<u64>,
/// Total number of rows across all pages.
pub total_row_count: u32,
/// Number of datapoints (Dune-specific).
pub datapoint_count: u32,
/// Time spent in queue before execution started (milliseconds).
pub pending_time_millis: Option<u32>,
/// Time spent executing the query (milliseconds).
pub execution_time_millis: u32,
}

Expand Down Expand Up @@ -113,9 +137,13 @@ pub struct ExecutionTimes {
/// Indicates the current state of execution along with some metadata.
#[derive(Deserialize, Debug)]
pub struct GetStatusResponse {
/// Same execution ID used in the status request.
pub execution_id: String,
/// The Dune query ID that was executed.
pub query_id: u32,
/// Current execution state; use [`ExecutionStatus::is_terminal`] to check if done.
pub state: ExecutionStatus,
/// Timestamps for submitted_at, expires_at, execution_started_at, etc.
#[serde(flatten)]
pub times: ExecutionTimes,
/// If the query state is Pending,
Expand All @@ -130,7 +158,9 @@ pub struct GetStatusResponse {
/// as the `result` field.
#[derive(Deserialize, Debug)]
pub struct ExecutionResult<T> {
/// Deserialized result rows; `T` is your row type (e.g. a struct with `#[derive(Deserialize)]`).
pub rows: Vec<T>,
/// Column names, row counts, and timing info.
pub metadata: ResultMetaData,
}

Expand All @@ -139,21 +169,43 @@ pub struct ExecutionResult<T> {
/// except that [ResultMetaData](ResultMetaData) is contained within the `result` field.
#[derive(Deserialize, Debug)]
pub struct GetResultResponse<T> {
/// Execution ID for this result.
pub execution_id: String,
/// The Dune query ID that was executed.
pub query_id: u32,
/// Optional flag indicating whether execution is finished.
#[serde(default)]
pub is_execution_finished: Option<bool>,
/// Final state (typically [`ExecutionStatus::Complete`] when results are available).
pub state: ExecutionStatus,
// TODO - this `flatten` isn't what I had hoped for.
// I want the `times` field to disappear
// and all sub-fields to be brought up to this layer.
/// Timestamps for submitted_at, expires_at, execution_started_at, etc.
#[serde(flatten)]
pub times: ExecutionTimes,
/// The result set (rows and metadata).
pub result: ExecutionResult<T>,
}

impl<T> GetResultResponse<T> {
/// Convenience method for fetching the "deeply" nested `rows` of the result response.
///
/// # Example
///
/// ```no_run
/// use duners::{DuneClient, DuneRequestError, GetResultResponse};
/// use serde::Deserialize;
///
/// #[derive(Deserialize)]
/// struct Row { symbol: String, max_price: f64 }
///
/// # async fn run() -> Result<(), DuneRequestError> {
/// let client = DuneClient::from_env();
/// let response: GetResultResponse<Row> = client.refresh(971694, None, None).await?;
/// let rows = response.get_rows();
/// # Ok(()) }
/// ```
pub fn get_rows(self) -> Vec<T> {
self.result.rows
}
Expand Down