#uuid #sqlx #type #serde #human-readable #object-id #kinds

kind

Costless typed identifiers backed by UUID, with kind readable in serialized versions

4 releases (1 stable)

1.0.0 Feb 26, 2024
0.3.0 Feb 16, 2024
0.2.0 Feb 8, 2024
0.1.0 Feb 5, 2024

#293 in Rust patterns

MIT license

35KB
584 lines

MIT Latest Version Docs badge

Why kind ?

With kind, you

  • use typed identifiers in Rust, with no overhead over Uuid
  • have the type be human readable and obvious in JSON and any export
  • still use uuid in your database (if enabling the sqlx feature)
  • don't have to add code for that, never explicitly stringify, parse, check types, etc.
  • have no boilerplate to declare types and identifiers
  • have your ids implement Copy, Display, FromStr, Serialize, Deserialize, Eq, Hash, etc.
  • safely deal with both identified objects and new ones of same kind

See also the introduction to Kind motives and operation.

Optional features

  • serde: Serialize and Deserialize implementations for Id, Ided, and the id_enum! enums
  • sqlx: transparent read/write for Id (with uuid columns) and for Ided (with tables having an uuid identifier)
  • jsonschema: JSON schema generation
  • openapi: openapi ID object type for Id

In the current version, the sqlx feature is only complete for postgresql.

Declare a kind of object

You could implement the Identifiable trait, but the easiest solution is to just add attributes to your structs:

use kind::*;

#[derive(Identifiable)]
#[kind(class="Cust")]
pub struct Customer {
    // many fields
}

#[derive(Identifiable)]
#[kind(class="Cont")]
pub struct Contract {
    // many fields
}

Id

A kind::Id is strongly typed to avoid misuse of Rust APIs, especially when functions ask for several ids of different types.

The kind::Id also prevents the misuse of any string based API, such as Rest or GraphQL, by prefixing the internally used ids with a class prefix.

It's costless: the kind is handled by the type system and doesn't clutter the compiled binary

assert_eq!(
    std::mem::size_of::<Id<Customer>>(),
    std::mem::size_of::<uuid::Uuid>(),
);

You can parse the id from eg JSON, or just a string

let id: Id<Customer> = "Cust_371c35ec-34d9-4315-ab31-7ea8889a419a"
    .parse().unwrap();

The type is checked, so this customer id can't be misused as a contract id

assert!(
    "Cust_371c35ec-34d9-4315-ab31-7ea8889a419a"
    .parse::<Id<Contract>>()
    .is_err()
);

Note: the public id is parsed and checked in a case insensitive way

assert_eq!(id, "cust_371c35ec-34d9-4315-ab31-7ea8889a419a".parse());
assert_eq!(id, "CUST_371C35EC-34D9-4315-AB31-7EA8889A419A".parse());

Ided

Ided is short for "identified".

Sometimes, you have to deal with raw objects without id, because that's what you receive from your REST api for creation, or because you give it an id only when inserting the row in database.

That's why our raw Customer type has no id. Most API don't deal with just the raw Customer type but with an Ided<Customer>, which is guaranteed to have an id.

An ided can be created from an id and an "entity":

let new_customer = Customer { name: "John".to_string() };
let customer = Ided::new(id, new_customer);
assert_eq!(
    customer.id().to_string(),
    "Cust_371c35ec-34d9-4315-ab31-7ea8889a419a"
);
assert_eq!(customer.entity().name, "John");

An ided automatically derefs into the entity type, so this is valid too:

assert_eq!(customer.name, "John");

Serde

An Ided object is serialized with the id next to the other fields, without unnecessary nesting.

#[derive(Identifiable, serde::Serialize, serde::Deserialize)]
#[kind(class="Cust")]
pub struct Customer {
    pub name: String,
}

let json = r#"{
    "id": "Cust_371c35ec-34d9-4315-ab31-7ea8889a419a",
    "name": "John"
}"#;

let customer: Ided<Customer> = serde_json::from_str(&json).unwrap();
assert_eq!(
    customer.id().to_string(),
    "Cust_371c35ec-34d9-4315-ab31-7ea8889a419a"
);
assert_eq!(customer.name, "John");

The id kind is checked, the deserialization below fails because the prefix of the id is wrong:

let json = r#"{
    "id": "Con_371c35ec-34d9-4315-ab31-7ea8889a419a",
    "name": "John"
}"#;
assert!(serde_json::from_str::<Ided<Customer>>(&json).is_err());

sqlx/PostgreSQL

In database, the id is just an uuid. The kind of the id in the database is implicitly given by the query and your DB structure, there's no additional check on reading/writing from rust to the DB and you don't have to change the DB structure when starting to use Kind.

The Id type implements Encode and Decode, so that it can be used transparently in sqlx queries just like any other primary type (arrays of Id are supported too, which is convenient with PostgreSQL).

As for serde, FromRow implementation on Ided is automatically deduced from the implementation on the raw struct.

So you will usually just declare your struct like this to have the Ided loaded from an sqlx::Row containing both the id column and the ones of the raw struct:

#[derive(Identifiable, sqlx::FromRow)]
#[kind(class="Cust")]
pub struct Customer {
    pub name: String,
}

JSON schema

If you are generating JSON schema for your objects using schemars crate, you can enable jsonschema feature, and we will generate definition for the Id object and any Ided object:

#[derive(JsonSchema, Identifiable)]
#[kind(class="Cust")]
pub struct Customer {
    pub name: String
}

fn main() {
    let schema = schema_for!(Ided<Customer>);
    println!("{}", serde_json::to_string_pretty(&schema).unwrap());
}

will print out

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Customer_ided",
  "description": "Identified version of Customer",
  "type": "object",
  "properties": {
    "id": {
      "type": "string",
      "format": "string"
    },
    "name": {
      "type": "string"
    }
  }
}

Open API

Open APi support (gated behind openapi feature flag) is currently extremely rudimentary. So far the only supported feature is defining the schema-level Id object that can be referenced by other schemas.

Example for including the Id into generated schema:

pub struct ApiDoc;
impl utoipa::OpenApi for ApiDoc {
    fn openapi() -> utoipa::openapi::OpenApi {
        let mut components = utoipa::openapi::ComponentsBuilder::new();
        let (kind_name, kind_schema) = kind::openapi_schema();
        components = components.schema(kind_name, kind_schema);
        //extra components and paths
        let mut openapi = utoipa::openapi::OpenApiBuilder::new()
            .components(Some(components.build()))
            .build();
        openapi
    }
}

Dependencies

~2–16MB
~243K SLoC