Skip to content
Open
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
4 changes: 4 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ Query: `?limit=` (default 50, clamped 1–200).

> Pagination is limit-only — there's no cursor or offset, so you can't page beyond the most recent 200.

**Supports `ETag`/`If-None-Match`.** The response carries an `ETag` header hashed from the payload. Send it back as `If-None-Match` on the next poll; an unchanged result comes back as `304 Not Modified` with an empty body instead of the full array.

### `GET /payment-requests/{id}`
**No auth** — deliberately public, so a customer's device can read a request before paying.

Expand Down Expand Up @@ -260,6 +262,8 @@ Auth required. One row per asset the merchant has ever held. Returns `[]` for a
### `GET /transactions`
Auth required. Detected incoming payments, newest first. Query: `?limit=` (default 50, clamped 1–200).

**Supports `ETag`/`If-None-Match`.** Same as `GET /payment-requests` above — send the last `ETag` back as `If-None-Match` to get `304 Not Modified` when nothing changed.

`200` →
```json
[
Expand Down
32 changes: 31 additions & 1 deletion openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -195,17 +195,25 @@ paths:
get:
tags: [Payment requests]
summary: List the merchant's payment requests
description: Newest first, scoped to the authenticated merchant.
description: >
Newest first, scoped to the authenticated merchant. Supports
conditional requests: send back the `ETag` from a prior response as
`If-None-Match` to get `304 Not Modified` when nothing changed.
parameters:
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/IfNoneMatch'
responses:
'200':
description: Payment requests
headers:
ETag: { $ref: '#/components/headers/ETag' }
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/PaymentRequest' }
'304':
description: Not Modified — client's cached copy is still current
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }

Expand Down Expand Up @@ -251,16 +259,25 @@ paths:
get:
tags: [Money]
summary: Detected incoming payments
description: >
Supports conditional requests: send back the `ETag` from a prior
response as `If-None-Match` to get `304 Not Modified` when nothing
changed.
parameters:
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/IfNoneMatch'
responses:
'200':
description: Payments
headers:
ETag: { $ref: '#/components/headers/ETag' }
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/Payment' }
'304':
description: Not Modified — client's cached copy is still current
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }

Expand Down Expand Up @@ -335,6 +352,19 @@ components:
required: false
description: Max rows to return. Clamped server-side to 1-200.
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
IfNoneMatch:
name: If-None-Match
in: header
required: false
description: >
ETag from a prior response. If it still matches, the server returns
304 Not Modified instead of the full payload.
schema: { type: string }

headers:
ETag:
description: Hash of the response payload. Echo it back as If-None-Match to poll cheaply.
schema: { type: string }

responses:
BadRequest:
Expand Down
9 changes: 7 additions & 2 deletions src/api/payment_requests.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
use axum::extract::{Path, Query, State};
use axum::http::HeaderMap;
use axum::response::Response;
use axum::Json;
use chrono::{DateTime, Utc};
use serde::Serialize;
use uuid::Uuid;

use crate::auth::extractor::AuthUser;
use crate::error::{bad_request, internal, not_found, ApiResult};
use crate::etag;
use crate::models::{CreatePaymentRequestRequest, PaymentRequest};
use crate::services::{payment_requests, wallets};
use crate::AppState;
Expand Down Expand Up @@ -81,7 +84,8 @@ pub async fn list(
State(state): State<AppState>,
auth: AuthUser,
Query(params): Query<ListParams>,
) -> ApiResult<Json<Vec<PaymentRequestView>>> {
headers: HeaderMap,
) -> ApiResult<Response> {
let merchant_id = auth
.merchant_id
.ok_or_else(|| bad_request("no merchant associated with this account"))?;
Expand All @@ -91,7 +95,8 @@ pub async fn list(
.await
.map_err(internal)?;

Ok(Json(rows.iter().map(row_to_view).collect()))
let views: Vec<PaymentRequestView> = rows.iter().map(row_to_view).collect();
Ok(etag::conditional_json(&headers, &views))
}

#[derive(serde::Deserialize)]
Expand Down
10 changes: 6 additions & 4 deletions src/api/transactions.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
use axum::extract::{Query, State};
use axum::Json;
use axum::http::HeaderMap;
use axum::response::Response;
use serde::Deserialize;

use crate::auth::extractor::AuthUser;
use crate::error::{bad_request, internal, ApiResult};
use crate::models::Payment;
use crate::etag;
use crate::services::payments;
use crate::AppState;

Expand All @@ -17,13 +18,14 @@ pub async fn list(
State(state): State<AppState>,
auth: AuthUser,
Query(params): Query<ListParams>,
) -> ApiResult<Json<Vec<Payment>>> {
headers: HeaderMap,
) -> ApiResult<Response> {
let merchant_id = auth
.merchant_id
.ok_or_else(|| bad_request("no merchant associated with this account"))?;
let limit = params.limit.unwrap_or(50).clamp(1, 200);
let payments = payments::payments_by_merchant(&state.db, merchant_id, limit)
.await
.map_err(internal)?;
Ok(Json(payments))
Ok(etag::conditional_json(&headers, &payments))
}
50 changes: 50 additions & 0 deletions src/etag.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
use axum::body::Body;
use axum::http::{header, HeaderMap, Response as HttpResponse, StatusCode};
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use sha2::{Digest, Sha256};

/// Serializes `body` to JSON, tags it with an ETag derived from a hash of the
/// payload, and returns `304 Not Modified` (no body) when the request's
/// `If-None-Match` already matches — sparing polling frontends the bandwidth
/// of a payload they already have.
pub fn conditional_json<T: Serialize>(headers: &HeaderMap, body: &T) -> Response {
let payload = match serde_json::to_vec(body) {
Ok(p) => p,
Err(err) => {
tracing::error!(error = %err, "failed to serialize response for etag");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let hash = Sha256::digest(&payload);
let etag = format!("\"{hash:x}\"");

if headers
.get(header::IF_NONE_MATCH)
.and_then(|v| v.to_str().ok())
.is_some_and(|if_none_match| matches_etag(if_none_match, &etag))
{
return HttpResponse::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::ETAG, etag)
.body(Body::empty())
.expect("valid 304 response")
.into_response();
}

HttpResponse::builder()
.status(StatusCode::OK)
.header(header::ETAG, etag)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(payload))
.expect("valid 200 response")
.into_response()
}

/// `If-None-Match` may carry a comma-separated list of ETags, or `*` to match any.
fn matches_etag(if_none_match: &str, etag: &str) -> bool {
if if_none_match.trim() == "*" {
return true;
}
if_none_match.split(',').any(|candidate| candidate.trim() == etag)
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod auth;
pub mod blockchain;
mod config;
mod error;
mod etag;
mod middleware;
mod models;
pub mod payments;
Expand Down