incident-io 2.7.0

Rust client for the incident.io API
Documentation

incident.io Rust SDK

crates.io docs.rs

The official Rust client for the incident.io public API.

It is generated automatically from our published OpenAPI schema, so it always tracks the live API — there is a function for every endpoint, and a type for every request and response.

Install

cargo add incident-io tokio --features tokio/macros,tokio/rt-multi-thread

Requires Rust 1.88 or later.

Every endpoint is async and there is no blocking variant, so you need an async runtime. The examples below use tokio; any runtime works, since the client is built on reqwest.

Quickstart

Create an API key in your incident.io dashboard under Settings → API keys, then:

use incident_io::apis::{configuration::Configuration, incidents_v2_api};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut config = Configuration::new();
    config.bearer_access_token = Some("my-api-key".to_owned());

    let params = incidents_v2_api::IncidentsV2ListParams::new().set_page_size(25);
    let result = incidents_v2_api::incidents_v2_list(&config, params).await?;

    for incident in result.incidents {
        println!("{} {}", incident.reference, incident.name);
    }

    Ok(())
}

Every endpoint is an async function taking a &Configuration and a params struct. 19 endpoints have no parameters at all, and still take one: severities_v1_list(&config, SeveritiesV1ListParams::new()). That is deliberate — the first parameter the API adds to one of those is then a field on a struct that already exists, rather than a change to the function's signature, which nothing can absorb.

new() takes the endpoint's required parameters, positionally. Optional ones are chainable set_* methods, which take the value rather than Some(value):

// No required parameters.
let params = incidents_v2_api::IncidentsV2ListParams::new()
    .set_page_size(25)
    .set_sort_by("created_at_newest_first");

// One required path parameter.
let params = incidents_v2_api::IncidentsV2ShowParams::new("01ABC...");

Request payloads work the same way, and nest:

use incident_io::models::incidents_create_payload_v2::Visibility;
use incident_io::models::IncidentsCreatePayloadV2;

let body = IncidentsCreatePayloadV2::new(idempotency_key, Visibility::Public)
    .set_name("Checkout is down");

let params = incidents_v2_api::IncidentsV2CreateParams::new(body);

Forgetting a required parameter is a compile error. Adding an optional one never is — see Adding fields.

Filtering

List endpoints filter on an operator map: {operator: [values]}, sent as created_at[gte]=2024-05-01. The accepted operators differ per field and are in each parameter's docs — commonly one_of, not_in, gte, lte.

use std::collections::HashMap;

let mut created_at = HashMap::new();
created_at.insert("gte".to_owned(), vec!["2024-05-01".to_owned()]);

let mut status = HashMap::new();
status.insert("one_of".to_owned(), vec!["01ABC".to_owned(), "01DEF".to_owned()]);

let params = incidents_v2_api::IncidentsV2ListParams::new()
    .set_created_at(created_at)
    .set_status(status);

Custom fields and incident roles nest one level further, keyed by the field's ID, and go on the wire as custom_field[<id>][one_of]=<value>:

let mut operators = HashMap::new();
operators.insert("one_of".to_owned(), vec!["01VALUE".to_owned()]);

let mut custom_field = HashMap::new();
custom_field.insert("01FIELD".to_owned(), operators);

let params = incidents_v2_api::IncidentsV2ListParams::new().set_custom_field(custom_field);

Pagination

List endpoints are cursor-paginated. Read the next cursor from pagination_meta.after and pass it back:

let mut after: Option<String> = None;

loop {
    let mut params = incidents_v2_api::IncidentsV2ListParams::new().set_page_size(100);
    if let Some(cursor) = &after {
        params = params.set_after(cursor);
    }

    let page = incidents_v2_api::incidents_v2_list(&config, params).await?;

    for incident in &page.incidents {
        println!("{} {}", incident.reference, incident.name);
    }

    match page.pagination_meta.and_then(|meta| meta.after) {
        Some(cursor) => after = Some(cursor),
        None => break,
    }
}

Unknown enum values

We add values to enums as a backwards-compatible change, so every enum generated from a schema value carries an Unknown(String) variant and is marked #[non_exhaustive]. Match the variants you care about and let the wildcard handle the rest:

use incident_io::models::incident_v2::Mode;

match &incident.mode {
    Mode::Standard => println!("standard"),
    Mode::Retrospective => println!("retrospective"),
    other => println!("unhandled mode: {other:?}"),
}

The wildcard arm is mandatory, because these enums are #[non_exhaustive] — that is what makes a new value a non-breaking change rather than a compile error in your code.

The variant holds the value verbatim and serializes back to it unchanged, so reading a resource and writing it back doesn't discard a field this build doesn't recognise. The cost is that these enums are not Copy: match on a reference, or clone.

Adding fields

We add response properties, request properties and optional parameters as backwards-compatible changes. On an ordinary Rust struct each one is a hard break, so every generated type is #[non_exhaustive] — responses, request payloads and params structs alike.

That is why there are no struct literals in this README. Foo { .. } and ..Default::default() don't compile from outside the crate for these types — you get error[E0639]: cannot create non-exhaustive struct using struct expression, which doesn't say what to do instead. Use Foo::new(required...) and the set_* methods. They keep working when the API grows a field.

use incident_io::models::IncidentsListResultV2;

// Building one, for a fixture or a mock.
let result = IncidentsListResultV2::new();

// Reading one is unchanged: the fields are public.
for incident in &result.incidents {
    println!("{}", incident.reference);
}

new() on a response type takes no arguments, and on a request type it takes the required fields. The asymmetry is deliberate: the API adds required properties to responses regularly — 13 times in the 83 days we measured, about one a week — and if that widened a constructor's signature it would break you just as an added field would. It can't do the same to a request without breaking its own wire contract, so there the required fields are worth naming.

Configuration, ApiKey, ResponseContent and Error are #[non_exhaustive] too. They come from the code generator's templates rather than from the API schema, so an API change can't add a field to them — but a generator upgrade can, and its templates already gate extra fields behind options we don't set today. Marking them is the reversible direction: removing the attribute later is not a breaking change, adding it is.

So build them through their constructors rather than a struct literal, and match Error with a wildcard arm:

let mut config = Configuration::new();
config.bearer_access_token = Some("my-api-key".to_owned());

let response = ResponseContent::new(status, body, entity);

This is the same trade aws-sdk-s3 and google-cloud-storage make. Both are generated from a spec and released continuously at a stable major version, and both mark their request types as well as their responses.

Errors

Every endpoint returns Result<T, Error<SomethingError>>, where the inner enum has a variant per documented status code plus an UnknownValue(serde_json::Value) for anything else. Documenting another status code is a backwards-compatible change, so those enums are #[non_exhaustive] too and a wildcard arm is required:

use incident_io::apis::{Error, incidents_v2_api::IncidentsV2ListError};

match incidents_v2_api::incidents_v2_list(&config, params).await {
    Ok(result) => println!("{} incidents", result.incidents.len()),
    Err(Error::ResponseError(response)) => match response.entity {
        Some(IncidentsV2ListError::Status401(_)) => println!("bad API key"),
        Some(IncidentsV2ListError::Status429(_)) => println!("rate limited"),
        _ => println!("HTTP {}: {}", response.status, response.content),
    },
    Err(other) => println!("request failed: {other}"),
}

Configuration

Configuration::new() gives you the defaults; every field is public, so change what you need:

use std::time::Duration;
use incident_io::apis::configuration::Configuration;

let mut config = Configuration::new();
config.bearer_access_token = Some("my-api-key".to_owned());

// Identify your integration. Defaults to `incident-io-sdk-rust/<version>`.
config.user_agent = Some("my-app/1.0.0".to_owned());

// Defaults to https://api.incident.io.
config.base_path = "https://api.incident.io".to_owned();

// Timeouts, proxies, connection pooling and TLS all live on the client.
config.client = reqwest::Client::builder()
    .timeout(Duration::from_secs(30))
    .build()?;

There is no default request timeout. That is reqwest's behaviour, not a choice we made, and it means an unconfigured client can wait indefinitely. Set one unless you have a reason not to.

bearer_access_token is the one you need; basic_auth, oauth_access_token and api_key are emitted by the generator and unused by this API.

Retries

The client makes a single attempt per request and does not retry. We deliberately ship no retry layer: the usual crate for it, reqwest-middleware, would land in your dependency tree and change Configuration's public shape whether you wanted it or not.

The API rate-limits at 1200 requests/minute per key. This client does not expose response headers, so the Retry-After and X-RateLimit-* headers the API docs describe are not reachable from here. The 429 body carries the same information, and that you can read:

use incident_io::apis::{incidents_v2_api::IncidentsV2ListError, Error};

match incidents_v2_api::incidents_v2_list(&config, params).await {
    Err(Error::ResponseError(response)) => {
        if let Some(IncidentsV2ListError::Status429(body)) = &response.entity {
            if let Some(limit) = &body.rate_limit {
                // An RFC3339 timestamp, not a duration.
                println!("rate limited until {}", limit.retry_after);
            }
        }
    }
    _ => {}
}

retry_after is a timestamp rather than a duration, so it depends on your clock agreeing with ours — which is the one drawback the header form avoids. Retry on 429 and 5xx, and back off exponentially otherwise.

Endpoints that don't take an API key

Four generated functions send no Authorization header, because the schema marks them as needing no API key.

Two are public and need nothing: utilities_v1_ip_ranges and utilities_v1_open_apiv3.

The other two authenticate with the secret from the alert source or heartbeat you're posting to, not with an API key: alert_events_v2_create_http and heartbeat_v2_ping. bearer_access_token is ignored on both, so set the secret on the client:

use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};

let mut headers = HeaderMap::new();
headers.insert(
    AUTHORIZATION,
    HeaderValue::from_str(&format!("Bearer {alert_source_secret}"))?,
);

let mut config = Configuration::new();
config.client = reqwest::Client::builder()
    .default_headers(headers)
    .build()?;

Use a separate Configuration for these, so the source secret isn't sent on calls that expect an API key.

There are two heartbeat functions with the same documentation, because the schema documents both verbs on the same path: heartbeat_v2_ping is the POST and takes the source secret as above, heartbeat_v2_ping1 is the GET and takes an ordinary API key.

Deprecated endpoints

Deprecated endpoints stay available and carry #[deprecated], so rustc warns at the call site. Don't assume a v2 endpoint is current: 38 operations are deprecated today, and they span Catalog V2 (12), Follow-ups V2 (6), Actions V2 (5) and Actions V1 (2), as well as Custom Fields V1 (5), Incident Roles V1 (5) and Incidents V1 (3). Let the compiler tell you rather than going by the version in the name.

The release fails if the schema marks an endpoint deprecated and the generated code doesn't, so the warnings track the API rather than this list.

TLS

rustls by default. For OpenSSL instead:

incident-io = { version = "1", default-features = false, features = ["native-tls"] }

Cargo features are additive across a dependency graph, so another crate asking for native-tls brings it back in alongside rustls. Set the backend on the reqwest::Client explicitly if you need to be certain which one you get.

Turning both off is a compile error, rather than a client that cannot reach the API.

We depend on reqwest with its own default features off, so the client is HTTP/1.1 only and ignores macOS and Windows system proxy settings (HTTP_PROXY and friends still work). If you need either, build your own reqwest::Client with the features you want and assign it to config.client.

Versioning

Releases are cut automatically. A job checks the published API schema hourly, and when it has changed, regenerates this crate, runs the tests, and publishes a new minor version.

Two gates stand in front of that. oasdiff compares the old and new schemas, and cargo-semver-checks compares the Rust API against the last published crate. If either reports a breaking change the release stops and a human decides what to do, so a break is never published as a minor version.

Patch versions are only cut by hand, for a fix to this crate that isn't a schema change.

Support

Found a bug or missing something? Please open an issue. For questions about the API itself, see the API docs.

Note that everything under src/apis/ and src/models/ is generated — please don't send PRs editing it directly; changes there come from the upstream schema. See CONTRIBUTING.md if you want to work on the repo itself.

One naming quirk comes from the generator: an acronym at the start of an operation name is split. The API keys endpoints are a_pi_keys_v1_list, a_pi_keys_v1_create and so on in the api_keys_v1_api module, and the IP allowlist ones are i_p_allowlists_v1_* in ip_allowlists_v1_api. The module name is the searchable one.

License

MIT — see LICENSE.

This SDK's generated code is produced by openapi-generator, which is licensed under Apache 2.0.