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
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: CI

on:
push:
branches: [master]
pull_request:
branches: [master]

env:
CARGO_TERM_COLOR: always

jobs:
test:
name: Test (${{ matrix.rust }})
runs-on: ubuntu-latest
strategy:
matrix:
rust: [stable, beta, nightly]
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.rust }}
- name: Run tests
run: cargo test --verbose

clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Run clippy
run: cargo clippy -- -D warnings

fmt:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: Check formatting
run: cargo fmt --check
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ src/*.bk
tests/*.bk
*.swp
*.swo
/.claude/settings.local.json
43 changes: 43 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

jsonapi-rust is a Rust implementation of the JSON:API v1 specification (https://jsonapi.org/). It provides serialization, deserialization, and validation of JSON:API requests and responses.

## Build & Test Commands

```bash
cargo test # Run all tests
RUST_BACKTRACE=1 cargo test -- --nocapture # Verbose test output with backtraces
cargo test <test_name> # Run a single test
cargo clippy # Lint (run before fmt)
cargo fmt # Format code
```

## Architecture

### Module Structure

- **src/api.rs** - Core JSON:API types: `Resource`, `JsonApiDocument`, `Relationship`, validation logic, and diff/patch operations
- **src/model.rs** - `JsonApiModel` trait for converting Rust structs to/from JSON:API format, plus the `jsonapi_model!` macro
- **src/query.rs** - Query parameter parsing for include, fields, filter, sort, and pagination
- **src/array.rs** - `JsonApiArray` trait for optional "has many" relationships
- **src/errors.rs** - Error handling via thiserror

### Key Abstractions

1. **JsonApiDocument** - Enum that enforces JSON:API spec (document contains either data OR errors, never both)
2. **JsonApiModel trait** - Implement on structs with `id: String` field to enable JSON:API conversion
3. **jsonapi_model! macro** - Code generator: `jsonapi_model!(MyStruct; "resource-type")`
4. **Resource** - Core type with attributes (`HashMap<String, JsonApiValue>`), relationships, links, and meta

### Testing

Tests in `tests/` use JSON fixtures loaded via `helper::read_json_file()`. The test modules mirror the source structure: `api_test.rs`, `model_test.rs`, `query_test.rs`.

## Conventions

- Use conventional commits: `feat:`, `bug:`, `test:`, `doc:`, `refactor:` (changelog is auto-generated via clog-tool)
- Breaking changes are allowed until v1.0.0
12 changes: 6 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[package]
name = "jsonapi"
version = "0.7.0"
version = "0.8.0"
edition = "2021"
authors = ["Michiel Kalkman <michiel@nosuchtype.com>"]
description = "JSONAPI implementation"
documentation = "https://docs.rs/jsonapi"
Expand All @@ -12,15 +13,14 @@ categories = []
license = "MIT"

[dependencies]
serde = "^1.0.21"
serde_json = "^1.0.6"
serde_derive = "^1.0.21"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
queryst = "3"
log = "0.4"
error-chain = "^0.12.0"
thiserror = "2"

[dev-dependencies]
env_logger = "0.9"
env_logger = "0.11"

[badges]
travis-ci = { repository = "michiel/jsonapi-rust", branch = "master" }
58 changes: 26 additions & 32 deletions src/api.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
//! Defines custom types and structs primarily that composite the JSON:API
//! document
use serde_json;
use std::collections::HashMap;
use crate::errors::*;
use log::error;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr;
use std;

/// Permitted JSON-API values (all JSON Values)
pub type JsonApiValue = serde_json::Value;
Expand Down Expand Up @@ -163,7 +163,6 @@ pub struct Pagination {
pub last: Option<String>,
}


#[derive(Debug)]
pub struct Patch {
pub patch_type: PatchType,
Expand Down Expand Up @@ -300,20 +299,18 @@ impl FromStr for JsonApiDocument {
/// assert_eq!(doc.is_ok(), true);
/// ```
fn from_str(s: &str) -> Result<Self> {
serde_json::from_str(s).chain_err(|| "Error parsing Document")
Ok(serde_json::from_str(s)?)
}
}

impl Resource {
pub fn get_relationship(&self, name: &str) -> Option<&Relationship> {
match self.relationships {
None => None,
Some(ref relationships) => {
match relationships.get(name) {
None => None,
Some(rel) => Some(rel),
}
}
Some(ref relationships) => match relationships.get(name) {
None => None,
Some(rel) => Some(rel),
},
}
}

Expand Down Expand Up @@ -364,17 +361,11 @@ impl Resource {
other._type.clone(),
))
} else {

let mut self_keys: Vec<String> =
self.attributes.iter().map(|(key, _)| key.clone()).collect();
let mut self_keys: Vec<String> = self.attributes.keys().cloned().collect();

self_keys.sort();

let mut other_keys: Vec<String> = other
.attributes
.iter()
.map(|(key, _)| key.clone())
.collect();
let mut other_keys: Vec<String> = other.attributes.keys().cloned().collect();

other_keys.sort();

Expand All @@ -394,8 +385,7 @@ impl Resource {
None => {
error!(
"Resource::diff unable to find attribute {:?} in {:?}",
attr,
other
attr, other
);
}
Some(other_value) => {
Expand All @@ -409,7 +399,6 @@ impl Resource {
}
}
}

}

Ok(patchset)
Expand All @@ -420,10 +409,8 @@ impl Resource {
pub fn patch(&mut self, patchset: PatchSet) -> Result<Resource> {
let mut res = self.clone();
for patch in &patchset.patches {
res.attributes.insert(
patch.subject.clone(),
patch.next.clone(),
);
res.attributes
.insert(patch.subject.clone(), patch.next.clone());
}
Ok(res)
}
Expand Down Expand Up @@ -453,26 +440,33 @@ impl FromStr for Resource {
/// assert_eq!(data.is_ok(), true);
/// ```
fn from_str(s: &str) -> Result<Self> {
serde_json::from_str(s).chain_err(|| "Error parsing resource")
Ok(serde_json::from_str(s)?)
}
}


impl Relationship {
pub fn as_id(&self) -> std::result::Result<Option<&JsonApiId>, RelationshipAssumptionError> {
match self.data {
Some(IdentifierData::None) => Ok(None),
Some(IdentifierData::Multiple(_)) => Err(RelationshipAssumptionError::RelationshipIsAList),
Some(IdentifierData::Multiple(_)) => {
Err(RelationshipAssumptionError::RelationshipIsAList)
}
Some(IdentifierData::Single(ref data)) => Ok(Some(&data.id)),
None => Ok(None),
}
}

pub fn as_ids(&self) -> std::result::Result<Option<JsonApiIds>, RelationshipAssumptionError> {
pub fn as_ids(
&self,
) -> std::result::Result<Option<JsonApiIds<'_>>, RelationshipAssumptionError> {
match self.data {
Some(IdentifierData::None) => Ok(None),
Some(IdentifierData::Single(_)) => Err(RelationshipAssumptionError::RelationshipIsNotAList),
Some(IdentifierData::Multiple(ref data)) => Ok(Some(data.iter().map(|x| &x.id).collect())),
Some(IdentifierData::Single(_)) => {
Err(RelationshipAssumptionError::RelationshipIsNotAList)
}
Some(IdentifierData::Multiple(ref data)) => {
Ok(Some(data.iter().map(|x| &x.id).collect()))
}
None => Ok(None),
}
}
Expand Down
12 changes: 7 additions & 5 deletions src/array.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@ pub trait JsonApiArray<M> {
}

impl<M: JsonApiModel> JsonApiArray<M> for Vec<M> {
fn get_models(&self) -> &[M] { self }
fn get_models_mut(&mut self) -> &mut [M] { self }
fn get_models(&self) -> &[M] {
self
}
fn get_models_mut(&mut self) -> &mut [M] {
self
}
}

impl<M: JsonApiModel> JsonApiArray<M> for Option<Vec<M>> {
fn get_models(&self) -> &[M] {
self.as_ref()
.map(|v| v.as_slice())
.unwrap_or(&[][..])
self.as_ref().map(|v| v.as_slice()).unwrap_or(&[][..])
}

fn get_models_mut(&mut self) -> &mut [M] {
Expand Down
26 changes: 16 additions & 10 deletions src/errors.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
error_chain!{
foreign_links {
SerdeJson(serde_json::Error);
}
errors {
ResourceToModelError(t: String) {
description("Error converting Resource to Model")
display("Error converting Resource to Model: '{}'", t)
}
}
use thiserror::Error;

/// Error type for jsonapi operations
#[derive(Error, Debug)]
pub enum Error {
#[error("JSON parsing error: {0}")]
SerdeJson(#[from] serde_json::Error),

#[error("Error converting Resource to Model: '{0}'")]
ResourceToModel(String),

#[error("{0}")]
Other(String),
}

/// Result type alias for jsonapi operations
pub type Result<T> = std::result::Result<T, Error>;
Loading