Skip to content
Closed
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
83 changes: 53 additions & 30 deletions gts-macros/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ pub struct MyStructV1 { ... }
| **Property existence** | Every property in the list must exist as a field in the struct |
| **Struct type** | Only structs with named fields are supported (no tuple structs) |
| **Generic type constraints** | Generic type parameters must implement `GtsSchema` (only `()` or other GTS structs allowed) |
| **Base struct field validation** | Base structs (`base = true`) must have either ID fields OR GTS Type fields, but not both (see below) |
| **Base struct field validation** | Base structs (`base = true`) may optionally have ID fields OR GTS Type fields in `properties`, but not both. GTS fields must be in `properties` if present (see below) |

### Compile Error Examples

Expand Down Expand Up @@ -193,7 +193,21 @@ error[E0277]: the trait bound `MyStruct: GtsSchema` is not satisfied
| ^^^^^^^^^^^^^^^^^^^^^ the trait `GtsSchema` is not implemented for `MyStruct`
```

**Base struct field validation - ID fields:**
**Base struct without ID or type field (simplest form):**
```rust
#[struct_to_gts_schema(
dir_path = "schemas",
base = true,
schema_id = "gts.x.core.errors.quota_failure.v1~",
description = "Quota failure with one or more violations",
properties = "violations"
)]
pub struct QuotaFailureV1 {
pub violations: Vec<String>, // ✅ No type/id field needed
}
```

**Base struct with ID field in properties:**
```rust
use gts::gts::GtsInstanceId;

Expand All @@ -205,13 +219,13 @@ use gts::gts::GtsInstanceId;
properties = "id,name"
)]
pub struct BaseEventTopicV1<P> {
pub id: GtsInstanceId, // ✅ Valid ID field
pub id: GtsInstanceId, // ✅ Valid ID field (in properties)
pub name: String,
pub payload: P,
}
```

**Base struct field validation - GTS Type fields:**
**Base struct with GTS Type field in properties:**
```rust
use gts::gts::GtsSchemaId;

Expand All @@ -223,61 +237,70 @@ use gts::gts::GtsSchemaId;
properties = "r#type,name"
)]
pub struct BaseEventV1<P> {
pub id: Uuid, // Event UUID
pub r#type: GtsSchemaId, // Event Type - ✅ Valid GTS Type field
pub id: Uuid, // Event UUID (not a GTS field)
pub r#type: GtsSchemaId, // Event Type - ✅ Valid GTS Type field (in properties)
pub name: String,
pub payload: P,
}
```

**Invalid base struct - both ID and GTS Type fields:**
**GTS type/id field NOT in properties (compile error):**
```rust
#[struct_to_gts_schema(
dir_path = "schemas",
base = true,
schema_id = "gts.x.core.events.topic.v1~",
description = "Invalid base with both ID and type",
properties = "id,r#type,name" // ❌ Error! Both ID and GTS Type fields
schema_id = "gts.x.core.errors.rate_limit.v1~",
description = "Rate limit error",
properties = "retry_after"
)]
pub struct BaseEventV1<P> {
pub id: GtsInstanceId, // Event topic ID field
pub r#type: GtsSchemaId, // Event type (schema) ID field - ❌ Cannot have both!
pub struct RateLimitErrorV1 {
pub gts_type: GtsSchemaId, // ❌ Error! Must be in properties or removed
pub retry_after: u64,
}
```
```
error: struct_to_gts_schema: Field `gts_type` has type `GtsSchemaId` but is not listed
in `properties`. Either add `gts_type` to the `properties` list, or remove it
from the struct (use `RateLimitErrorV1::gts_schema_id()` or
`RateLimitErrorV1::SCHEMA_ID` to access the schema ID at runtime).
```
Comment thread
asmith987 marked this conversation as resolved.

**Invalid base struct - wrong GTS Type field type:**
**Invalid base struct - both ID and GTS Type fields in properties:**
```rust
#[struct_to_gts_schema(
dir_path = "schemas",
base = true,
schema_id = "gts.x.core.events.type.v1~",
description = "Base event with wrong type field",
properties = "r#type,name"
schema_id = "gts.x.core.events.topic.v1~",
description = "Invalid base with both ID and type",
properties = "id,r#type,name" // ❌ Error! Both ID and GTS Type fields in properties
)]
pub struct BaseEventV1<P> {
pub id: Uuid, // Event UUID
pub r#type: String, // Event type (schema) - ❌ Should be GtsSchemaId
pub name: String,
pub payload: P,
}
```
```
error: struct_to_gts_schema: Base structs with GTS Type fields must have at least one GTS Type field (type, gts_type, gtsType, or schema) of type GtsSchemaId
pub id: GtsInstanceId, // Event topic ID field
pub r#type: GtsSchemaId, // Event type (schema) ID field - ❌ Cannot have both!
```

### Base Struct Field Validation Rules

Base structs (`base = true`) must follow **exactly one** of these patterns:
Base structs (`base = true`) may **optionally** include ID or GTS Type fields. When present in `properties`, they are validated:

#### Option 1: No ID/Type Field
- The simplest form — no ID or type field needed
- Schema ID is always available via `Self::gts_schema_id()` through the `GtsSchema` trait
- Use case: Simple data structs, error types, configuration objects

#### Option 1: ID Fields
#### Option 2: ID Fields (in `properties`)
- **Supported field names**: `$id`, `id`, `gts_id`, `gtsId`
- **Required type**: `GtsInstanceId` (or `gts::GtsInstanceId`)
- **Use case**: Instance-based identification

#### Option 2: GTS Type Fields
#### Option 3: GTS Type Fields (in `properties`)
- **Supported field names**: `type`, `r#type`, `gts_type`, `gtsType`, `schema`
- **Supported serde renames**: Fields with `#[serde(rename = "type")]`, `#[serde(rename = "gts_type")]`, `#[serde(rename = "gtsType")]`, or `#[serde(rename = "schema")]`
- **Required type**: `GtsSchemaId` (or `gts::GtsSchemaId`)
- **Use case**: Schema-based identification
- **Use case**: Schema-based identification / type discriminator

#### GTS Fields Must Be in Properties
If a GTS type/id field (e.g., `gts_type: GtsSchemaId` or `id: GtsInstanceId`) is present on the struct, it **must** be listed in `properties`. If you don't need the field in JSON, simply remove it from the struct — the schema ID is always accessible via `Self::gts_schema_id()` or `Self::SCHEMA_ID`.

**Serde rename example:**
```rust
Expand All @@ -298,7 +321,7 @@ pub struct BaseEventV1<P> {
}
```

**Important**: Base structs cannot have both ID fields AND GTS Type fields. They must choose one approach.
**Important**: Base structs cannot have both ID fields AND GTS Type fields in `properties`. They must choose one approach (or use neither).

---

Expand Down
126 changes: 95 additions & 31 deletions gts-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,55 +235,53 @@ fn validate_base_struct_fields(
return Ok(());
}

// Check for presence of ID and GTS Type fields (including serde renames)
let has_id_field = fields.iter().any(|f| field_name_matches(f, ID_FIELD_NAMES));

let has_type_field = fields.iter().any(|f| {
field_name_matches(f, TYPE_FIELD_NAMES) || has_matching_serde_rename(f, SERDE_TYPE_RENAMES)
});

if !has_id_field && !has_type_field {
return Err(syn::Error::new_spanned(
&input.ident,
format!(
"struct_to_gts_schema: Base structs must have either an ID field (one of: {}) OR a GTS Type field (one of: {}), but not both.",
ID_FIELD_NAMES.join(", "),
TYPE_FIELD_NAMES.join(", ")
),
));
}
let property_names: Vec<String> = args
.properties
.split(',')
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.collect();

// Validate field types
validate_field_types(input, fields)
// Only validate type/id fields that are listed in properties.
// Fields not in properties are treated as regular fields (no GTS validation).
validate_field_types(input, fields, &property_names)
}

/// Validate that field types are correct for ID and GTS Type fields
/// Validate that field types are correct for ID and GTS Type fields.
/// Only validates fields that are listed in the `properties` list — fields not in
/// properties are treated as regular fields and not subject to GTS type validation.
fn validate_field_types(
input: &syn::DeriveInput,
fields: &syn::punctuated::Punctuated<syn::Field, syn::token::Comma>,
property_names: &[String],
) -> Result<(), syn::Error> {
// Only consider fields that are in the properties list
let is_in_properties = |field: &syn::Field| -> bool {
field
.ident
.as_ref()
.is_some_and(|ident| property_names.contains(&ident.to_string()))
};

let has_valid_id_field = fields.iter().any(|field| {
field_name_matches(field, ID_FIELD_NAMES) && is_type_gts_instance_id(&field.ty)
is_in_properties(field)
&& field_name_matches(field, ID_FIELD_NAMES)
&& is_type_gts_instance_id(&field.ty)
});

let has_valid_type_field = fields.iter().any(|field| {
let is_type_field = field_name_matches(field, TYPE_FIELD_NAMES)
|| has_matching_serde_rename(field, SERDE_TYPE_RENAMES);
is_type_field && is_type_gts_schema_id(&field.ty)
is_in_properties(field) && is_type_field && is_type_gts_schema_id(&field.ty)
});

// Enforce "either/or but not both" logic
// Enforce "either/or but not both" logic (only for valid GTS fields in properties).
// Fields with recognized names but non-GTS types (e.g., `id: Uuid`) are treated as
// regular fields and don't trigger this check.
if has_valid_id_field && has_valid_type_field {
return Err(syn::Error::new_spanned(
&input.ident,
"struct_to_gts_schema: Base structs must have either an ID field (one of: $id, id, gts_id, or gtsId) of type GtsInstanceId OR a GTS Type field (one of: type, gts_type, gtsType, or schema) of type GtsSchemaId, but not both. Found both valid ID and GTS Type fields.",
));
}

if !has_valid_id_field && !has_valid_type_field {
return Err(syn::Error::new_spanned(
&input.ident,
"struct_to_gts_schema: Base structs must have either an ID field (one of: $id, id, gts_id, or gtsId) of type GtsInstanceId OR a GTS Type field (one of: type, gts_type, gtsType, or schema) of type GtsSchemaId",
"struct_to_gts_schema: Base structs cannot have both an ID field (GtsInstanceId) and a GTS Type field (GtsSchemaId) in properties. Found both valid ID and GTS Type fields.",
));
}

Expand Down Expand Up @@ -590,6 +588,65 @@ fn add_gts_serde_attrs(input: &mut syn::DeriveInput, base: &BaseAttr) {
}
}

/// Validate that GTS type/id fields (e.g., `gts_type: GtsSchemaId`, `id: GtsInstanceId`) are
/// listed in `properties` when present on a base struct.
///
/// If a struct has a recognized GTS field but doesn't list it in `properties`, it's likely
/// a mistake — the macro already provides `SCHEMA_ID` and `gts_schema_id()` for accessing
/// the schema ID at runtime. Fields not in properties would be invisible to serde, so there's
/// no reason to declare them on the struct.
fn validate_gts_fields_in_properties(
input: &syn::DeriveInput,
base: &BaseAttr,
properties: &str,
) -> Result<(), syn::Error> {
if !matches!(base, BaseAttr::IsBase) {
return Ok(());
}

let property_names: Vec<String> = properties
.split(',')
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.collect();

if let syn::Data::Struct(ref data_struct) = input.data
&& let syn::Fields::Named(ref fields) = data_struct.fields
{
for field in &fields.named {
let Some(ident) = &field.ident else {
continue;
};
let field_name = ident.to_string();

let is_gts_id_field =
field_name_matches(field, ID_FIELD_NAMES) && is_type_gts_instance_id(&field.ty);
let is_gts_type_field = (field_name_matches(field, TYPE_FIELD_NAMES)
|| has_matching_serde_rename(field, SERDE_TYPE_RENAMES))
&& is_type_gts_schema_id(&field.ty);

if (is_gts_id_field || is_gts_type_field) && !property_names.contains(&field_name) {
let type_desc = if is_gts_type_field {
"GtsSchemaId"
} else {
"GtsInstanceId"
};
return Err(syn::Error::new_spanned(
ident,
format!(
"struct_to_gts_schema: Field `{field_name}` has type `{type_desc}` but is not listed in `properties`. \
Either add `{field_name}` to the `properties` list, or remove it from the struct \
(use `{}::gts_schema_id()` or `{}::SCHEMA_ID` to access the schema ID at runtime).",
input.ident, input.ident
),
));
}
Comment thread
asmith987 marked this conversation as resolved.
}
}

Ok(())
}

/// Build a custom where clause with additional trait bounds on generic params
fn build_where_clause(
generics: &syn::Generics,
Expand Down Expand Up @@ -953,6 +1010,13 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream
// For base structs with generic fields, add serde attributes for GtsSerialize/GtsDeserialize
add_gts_serde_attrs(&mut modified_input, &args.base);

// Validate that GTS type/id fields are listed in properties when present
if let Err(err) =
validate_gts_fields_in_properties(&modified_input, &args.base, &args.properties)
{
return err.to_compile_error().into();
}

// Validate base attribute consistency with schema_id segments
if let Err(err) = validate_base_segments(&input, &args.base, &args.schema_id) {
return err.to_compile_error().into();
Expand Down
20 changes: 10 additions & 10 deletions gts-macros/tests/compile_fail/base_parent_mismatch.stderr
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
error: struct_to_gts_schema: Base structs must have either an ID field (one of: $id, id, gts_id, or gtsId) of type GtsInstanceId OR a GTS Type field (one of: type, gts_type, gtsType, or schema) of type GtsSchemaId
--> tests/compile_fail/base_parent_mismatch.rs:15:12
error[E0080]: evaluation panicked: struct_to_gts_schema: Base struct 'BaseEventV1' schema ID must match parent segment 'gts.x.wrong.parent.type.v1~' from schema_id
--> tests/compile_fail/base_parent_mismatch.rs:23:1
|
15 | pub struct BaseEventV1<P> {
| ^^^^^^^^^^^

error[E0412]: cannot find type `BaseEventV1` in this scope
--> tests/compile_fail/base_parent_mismatch.rs:25:12
|
25 | base = BaseEventV1,
| ^^^^^^^^^^^ not found in this scope
23 | / #[struct_to_gts_schema(
24 | | dir_path = "schemas",
25 | | base = BaseEventV1,
26 | | schema_id = "gts.x.wrong.parent.type.v1~x.core.audit.event.v1~",
27 | | description = "This should fail",
28 | | properties = "user_id"
29 | | )]
| |__^ evaluation of `_::_` failed here
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
error: struct_to_gts_schema: Base structs must have either an ID field (one of: $id, id, gts_id, or gtsId) of type GtsInstanceId OR a GTS Type field (one of: type, gts_type, gtsType, or schema) of type GtsSchemaId
--> tests/compile_fail/base_parent_single_segment.rs:15:12
|
15 | pub struct BaseEventV1<P> {
| ^^^^^^^^^^^

error: struct_to_gts_schema: 'base' specifies a parent struct but schema_id 'gts.x.core.audit.event.v1~' has only 1 segment. A child type must have at least 2 segments. Either use 'base = true' or add parent segment to schema_id.
--> tests/compile_fail/base_parent_single_segment.rs:29:12
|
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
error: struct_to_gts_schema: Base structs must have either an ID field (one of: $id, id, gts_id, or gtsId) of type GtsInstanceId OR a GTS Type field (one of: type, gts_type, gtsType, or schema) of type GtsSchemaId, but not both. Found both valid ID and GTS Type fields.
error: struct_to_gts_schema: Base structs cannot have both an ID field (GtsInstanceId) and a GTS Type field (GtsSchemaId) in properties. Found both valid ID and GTS Type fields.
--> tests/compile_fail/base_struct_both_id_and_type.rs:14:12
|
14 | pub struct TopicV1BothIdAndTypeV1<P> {
Expand Down
19 changes: 0 additions & 19 deletions gts-macros/tests/compile_fail/base_struct_missing_id.rs

This file was deleted.

5 changes: 0 additions & 5 deletions gts-macros/tests/compile_fail/base_struct_missing_id.stderr

This file was deleted.

Loading