use std::collections::{HashMap, HashSet};
use serde::ser::Serialize;
use crate::model::ResourceObject;
#[must_use]
#[derive(Debug, Clone)]
pub struct FieldsetConfig {
fields: HashMap<String, HashSet<String>>,
}
impl FieldsetConfig {
pub fn new() -> Self {
Self {
fields: HashMap::new(),
}
}
pub fn fields(mut self, type_name: &str, fields: &[&str]) -> Self {
self.fields.insert(
type_name.to_string(),
fields.iter().map(|f| f.to_string()).collect(),
);
self
}
pub fn has_type(&self, type_name: &str) -> bool {
self.fields.contains_key(type_name)
}
pub fn is_included(&self, type_name: &str, field_name: &str) -> bool {
match self.fields.get(type_name) {
None => true,
Some(allowed) => allowed.contains(field_name),
}
}
}
impl Default for FieldsetConfig {
fn default() -> Self {
Self::new()
}
}
fn filter_resource_fields(
obj: &mut serde_json::Map<String, serde_json::Value>,
type_name: &str,
config: &FieldsetConfig,
) {
if let Some(attrs) = obj.get_mut("attributes").and_then(|v| v.as_object_mut()) {
attrs.retain(|key, _| config.is_included(type_name, key));
}
if obj
.get("attributes")
.and_then(|v| v.as_object())
.is_some_and(|m| m.is_empty())
{
obj.remove("attributes");
}
if let Some(rels) = obj.get_mut("relationships").and_then(|v| v.as_object_mut()) {
rels.retain(|key, _| config.is_included(type_name, key));
}
if obj
.get("relationships")
.and_then(|v| v.as_object())
.is_some_and(|m| m.is_empty())
{
obj.remove("relationships");
}
}
pub struct SparseSerializer<'a, T: ResourceObject> {
resource: &'a T,
config: &'a FieldsetConfig,
}
impl<T: ResourceObject + std::fmt::Debug> std::fmt::Debug for SparseSerializer<'_, T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SparseSerializer")
.field("resource", &self.resource)
.field("config", &self.config)
.finish()
}
}
impl<'a, T: ResourceObject> SparseSerializer<'a, T> {
#[must_use]
pub fn new(resource: &'a T, config: &'a FieldsetConfig) -> Self {
Self { resource, config }
}
}
impl<T: ResourceObject> Serialize for SparseSerializer<'_, T> {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let type_name = self.resource.resource_type();
if !self.config.has_type(type_name) {
return self.resource.serialize(serializer);
}
let mut value = serde_json::to_value(self.resource).map_err(serde::ser::Error::custom)?;
if let Some(obj) = value.as_object_mut() {
filter_resource_fields(obj, type_name, self.config);
}
value.serialize(serializer)
}
}
#[must_use]
pub fn sparse_filter(document: &serde_json::Value, config: &FieldsetConfig) -> serde_json::Value {
let mut doc = document.clone();
if let Some(obj) = doc.as_object_mut() {
if let Some(data) = obj.get_mut("data") {
filter_resource_or_array(data, config);
}
if let Some(included) = obj.get_mut("included").and_then(|v| v.as_array_mut()) {
for resource in included.iter_mut() {
filter_single_resource(resource, config);
}
}
}
doc
}
fn filter_resource_or_array(value: &mut serde_json::Value, config: &FieldsetConfig) {
match value {
serde_json::Value::Array(arr) => {
for item in arr.iter_mut() {
filter_single_resource(item, config);
}
}
serde_json::Value::Object(_) => {
filter_single_resource(value, config);
}
_ => {}
}
}
fn filter_single_resource(value: &mut serde_json::Value, config: &FieldsetConfig) {
let type_name = match value.get("type").and_then(|v| v.as_str()) {
Some(t) => t.to_string(),
None => return,
};
if !config.has_type(&type_name) {
return;
}
if let Some(obj) = value.as_object_mut() {
filter_resource_fields(obj, &type_name, config);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fieldset_config_new() {
let config = FieldsetConfig::new();
assert!(!config.has_type("articles"));
}
#[test]
fn test_fieldset_config_fields() {
let config = FieldsetConfig::new()
.fields("articles", &["title", "body"])
.fields("people", &["name"]);
assert!(config.has_type("articles"));
assert!(config.has_type("people"));
assert!(!config.has_type("tags"));
}
#[test]
fn test_is_included_with_fieldset() {
let config = FieldsetConfig::new().fields("articles", &["title"]);
assert!(config.is_included("articles", "title"));
assert!(!config.is_included("articles", "body"));
}
#[test]
fn test_is_included_no_fieldset_passes_all() {
let config = FieldsetConfig::new();
assert!(config.is_included("articles", "title"));
assert!(config.is_included("anything", "whatever"));
}
#[test]
fn test_is_included_unknown_field_in_config() {
let config = FieldsetConfig::new().fields("articles", &["title", "nonexistent"]);
assert!(config.is_included("articles", "title"));
assert!(!config.is_included("articles", "body"));
assert!(config.is_included("articles", "nonexistent"));
}
#[test]
fn test_sparse_filter_single_resource() {
let doc = serde_json::json!({
"data": {
"type": "articles", "id": "1",
"attributes": {"title": "Hello", "body": "World"},
"relationships": {
"author": {"data": {"type": "people", "id": "9"}}
}
}
});
let config = FieldsetConfig::new().fields("articles", &["title"]);
let filtered = sparse_filter(&doc, &config);
assert_eq!(filtered["data"]["type"], "articles");
assert_eq!(filtered["data"]["id"], "1");
assert_eq!(filtered["data"]["attributes"]["title"], "Hello");
assert!(filtered["data"]["attributes"].get("body").is_none());
assert!(filtered["data"].get("relationships").is_none());
}
#[test]
fn test_sparse_filter_array_data() {
let doc = serde_json::json!({
"data": [
{"type": "articles", "id": "1", "attributes": {"title": "First", "body": "One"}},
{"type": "articles", "id": "2", "attributes": {"title": "Second", "body": "Two"}}
]
});
let config = FieldsetConfig::new().fields("articles", &["title"]);
let filtered = sparse_filter(&doc, &config);
assert_eq!(filtered["data"][0]["attributes"]["title"], "First");
assert!(filtered["data"][0]["attributes"].get("body").is_none());
assert_eq!(filtered["data"][1]["attributes"]["title"], "Second");
assert!(filtered["data"][1]["attributes"].get("body").is_none());
}
#[test]
fn test_sparse_filter_included() {
let doc = serde_json::json!({
"data": {
"type": "articles", "id": "1",
"attributes": {"title": "Hello", "body": "World"}
},
"included": [{
"type": "people", "id": "9",
"attributes": {"name": "Dan", "email": "dan@example.com"}
}]
});
let config = FieldsetConfig::new()
.fields("articles", &["title"])
.fields("people", &["name"]);
let filtered = sparse_filter(&doc, &config);
assert_eq!(filtered["data"]["attributes"]["title"], "Hello");
assert!(filtered["data"]["attributes"].get("body").is_none());
assert_eq!(filtered["included"][0]["attributes"]["name"], "Dan");
assert!(filtered["included"][0]["attributes"].get("email").is_none());
}
#[test]
fn test_sparse_filter_no_config_passes_through() {
let doc = serde_json::json!({
"data": {
"type": "articles", "id": "1",
"attributes": {"title": "Hello", "body": "World"}
}
});
let config = FieldsetConfig::new();
let filtered = sparse_filter(&doc, &config);
assert_eq!(filtered["data"]["attributes"]["title"], "Hello");
assert_eq!(filtered["data"]["attributes"]["body"], "World");
}
#[test]
fn test_sparse_filter_null_data() {
let doc = serde_json::json!({
"data": null
});
let config = FieldsetConfig::new().fields("articles", &["title"]);
let filtered = sparse_filter(&doc, &config);
assert!(filtered["data"].is_null());
}
#[test]
fn test_sparse_filter_preserves_non_data_fields() {
let doc = serde_json::json!({
"data": {
"type": "articles", "id": "1",
"attributes": {"title": "Hello"}
},
"meta": {"total": 1},
"jsonapi": {"version": "1.1"}
});
let config = FieldsetConfig::new().fields("articles", &["title"]);
let filtered = sparse_filter(&doc, &config);
assert_eq!(filtered["meta"]["total"], 1);
assert_eq!(filtered["jsonapi"]["version"], "1.1");
}
}