Appearance
Using the API from Rust
This example uses reqwest (blocking client) for HTTP and serde with serde_json for JSON. It needs an API token and the COCOCO_URL and COCOCO_TOKEN environment variables described in Calling the CoCoCo API.
The operations
graphql
query ListJobs($first: Int, $after: String) {
listJobs(first: $first, after: $after) {
totalCount
pageInfo { hasNextPage endCursor }
edges { node { id name quantity status dueAt } }
}
}graphql
mutation CreateJob($input: CreateJobInput!) {
createJob(input: $input) {
job { id name status }
errors { path message }
}
}The program
Create a project with cargo new jobs and add the dependencies to Cargo.toml:
toml
[dependencies]
reqwest = { version = "0.12", features = ["blocking", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"Replace src/main.rs with the program below. cargo run lists every job; cargo run -- create creates one.
rust
use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde_json::{json, Value};
use std::{env, error::Error, time::Duration};
const LIST_JOBS: &str = r#"query ListJobs($first: Int, $after: String) {
listJobs(first: $first, after: $after) {
totalCount
pageInfo { hasNextPage endCursor }
edges { node { id name quantity status dueAt } }
}
}"#;
const CREATE_JOB: &str = r#"mutation CreateJob($input: CreateJobInput!) {
createJob(input: $input) {
job { id name status }
errors { path message }
}
}"#;
type Result<T> = std::result::Result<T, Box<dyn Error>>;
#[derive(Deserialize)]
struct Response<T> {
data: Option<T>,
#[serde(default)]
errors: Vec<GraphQLError>,
}
#[derive(Deserialize)]
struct GraphQLError {
message: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Job {
id: String, // a JobID such as "job_01M0SYJHB7FX6BKYVZZ7EAG4Y8"
name: String,
#[serde(default)]
quantity: i64,
status: String,
due_at: Option<String>, // ISO 8601
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ListJobsData {
list_jobs: JobConnection,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct JobConnection {
page_info: PageInfo,
edges: Vec<JobEdge>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PageInfo {
has_next_page: bool,
end_cursor: Option<String>,
}
#[derive(Deserialize)]
struct JobEdge {
node: Job,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreateJobData {
create_job: CreateJobPayload,
}
#[derive(Deserialize)]
struct CreateJobPayload {
job: Option<Job>,
errors: Vec<FieldError>,
}
#[derive(Deserialize)]
struct FieldError {
path: String,
message: String,
}
struct Client {
http: reqwest::blocking::Client,
endpoint: String,
token: String,
}
impl Client {
/// Posts one operation and returns its data.
/// Fails on an HTTP error status and on any top-level GraphQL error.
fn execute<T: DeserializeOwned>(&self, query: &str, variables: Value) -> Result<T> {
let response: Response<T> = self
.http
.post(&self.endpoint)
.bearer_auth(&self.token)
.json(&json!({ "query": query, "variables": variables }))
.send()?
.error_for_status()?
.json()?;
// Authentication, permission and query errors arrive here with HTTP 200.
if !response.errors.is_empty() {
let messages: Vec<String> = response.errors.into_iter().map(|e| e.message).collect();
return Err(messages.join("; ").into());
}
response.data.ok_or_else(|| "response has no data".into())
}
/// Returns every job, fetching 100 per request.
fn list_jobs(&self) -> Result<Vec<Job>> {
let mut jobs = Vec::new();
let mut after: Option<String> = None;
loop {
let data: ListJobsData =
self.execute(LIST_JOBS, json!({ "first": 100, "after": after }))?;
let page = data.list_jobs;
jobs.extend(page.edges.into_iter().map(|edge| edge.node));
if !page.page_info.has_next_page {
return Ok(jobs);
}
after = page.page_info.end_cursor;
}
}
/// Creates a job. Validation problems come back in the payload.
fn create_job(&self, name: &str, quantity: i64, external_id: &str) -> Result<Job> {
let input = json!({ "name": name, "quantity": quantity, "externalId": external_id });
let data: CreateJobData = self.execute(CREATE_JOB, json!({ "input": input }))?;
let payload = data.create_job;
if let Some(e) = payload.errors.first() {
return Err(format!("{}: {}", e.path, e.message).into());
}
payload.job.ok_or_else(|| "no job returned".into())
}
}
fn main() -> Result<()> {
let client = Client {
http: reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()?,
endpoint: format!("{}/graphql", env::var("COCOCO_URL")?.trim_end_matches('/')),
token: env::var("COCOCO_TOKEN")?,
};
if env::args().nth(1).as_deref() == Some("create") {
let job = client.create_job("Business cards", 500, "ERP-10042")?;
println!("Created {} ({})", job.id, job.status);
} else {
for job in client.list_jobs()? {
let due = job.due_at.as_deref().unwrap_or("-");
println!(
"{} {} {} {} {}",
job.id, job.status, job.quantity, job.name, due
);
}
}
Ok(())
}Notes
executefails for both kinds of failure: an HTTP error status (error_for_status), and a response whoseerrorsarray is not empty.list_jobsfollowspageInfo.endCursoruntilhasNextPageisfalse. The API returns at most 100 items per page.- The create mutation selects only
id,nameandstatus, which is whyquantityonJobhas#[serde(default)].due_atis anOption, so serde leaves itNonewhen it is missing. externalIdmust be unique in your tenant. Runningcreatetwice with the same value returns a validation error increateJob.errors.