cf_resource_group_sdk/models.rs
1// Created: 2026-04-16 by Constructor Tech
2// Updated: 2026-04-28 by Constructor Tech
3// @cpt-begin:cpt-cf-resource-group-dod-sdk-foundation-sdk-models:p1:inst-full
4// @cpt-dod:cpt-cf-resource-group-dod-sdk-foundation-sdk-models:p1
5//! SDK model types for the resource-group module.
6//!
7//! These types form the public contract between the resource-group module
8//! and its consumers. They are transport-agnostic and use string-based
9//! GTS type paths (no surrogate SMALLINT IDs).
10
11use std::fmt;
12
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16// -- GtsTypePath value object --
17
18// @cpt-begin:cpt-cf-resource-group-algo-sdk-foundation-validate-gts-type-path:p1:inst-gts-val-1
19/// Maximum length of a GTS type path.
20const GTS_TYPE_PATH_MAX_LEN: usize = 1024;
21
22/// Validated GTS type path value object.
23///
24/// A GTS type path follows the pattern `gts.<segment>~(<segment>~)*` where
25/// each segment consists of lowercase alphanumeric characters, underscores,
26/// and dots. Examples: `gts.cf.core.rg.type.v1~`, `gts.cf.core.rg.type.v1~cf.core._.tenant.v1~`.
27#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(try_from = "String", into = "String")]
29pub struct GtsTypePath(String);
30
31impl GtsTypePath {
32 /// Create a new `GtsTypePath` from a raw string, applying validation.
33 ///
34 /// # Errors
35 /// Returns an error if the string is empty, exceeds 1024 characters,
36 /// or does not match the GTS type path format.
37 pub fn new(raw: impl Into<String>) -> Result<Self, String> {
38 // @cpt-begin:cpt-cf-resource-group-algo-sdk-foundation-validate-gts-type-path:p1:inst-gts-val-2
39 let raw = raw.into();
40 let s = raw.trim().to_lowercase();
41 // @cpt-end:cpt-cf-resource-group-algo-sdk-foundation-validate-gts-type-path:p1:inst-gts-val-2
42
43 // @cpt-begin:cpt-cf-resource-group-algo-sdk-foundation-validate-gts-type-path:p1:inst-gts-val-3
44 if s.is_empty() {
45 // @cpt-begin:cpt-cf-resource-group-algo-sdk-foundation-validate-gts-type-path:p1:inst-gts-val-3a
46 return Err("GTS type path must not be empty".to_owned());
47 // @cpt-end:cpt-cf-resource-group-algo-sdk-foundation-validate-gts-type-path:p1:inst-gts-val-3a
48 }
49 // @cpt-end:cpt-cf-resource-group-algo-sdk-foundation-validate-gts-type-path:p1:inst-gts-val-3
50
51 // @cpt-begin:cpt-cf-resource-group-algo-sdk-foundation-validate-gts-type-path:p1:inst-gts-val-5
52 if s.len() > GTS_TYPE_PATH_MAX_LEN {
53 // @cpt-begin:cpt-cf-resource-group-algo-sdk-foundation-validate-gts-type-path:p1:inst-gts-val-5a
54 return Err("GTS type path exceeds maximum length".to_owned());
55 // @cpt-end:cpt-cf-resource-group-algo-sdk-foundation-validate-gts-type-path:p1:inst-gts-val-5a
56 }
57 // @cpt-end:cpt-cf-resource-group-algo-sdk-foundation-validate-gts-type-path:p1:inst-gts-val-5
58
59 // @cpt-begin:cpt-cf-resource-group-algo-sdk-foundation-validate-gts-type-path:p1:inst-gts-val-4
60 // Validate format using the canonical gts crate parser.
61 // Each tilde-separated segment must be a valid GTS ID with 5+ tokens
62 // (vendor.package.namespace.type.vMAJOR).
63 if gts::GtsID::new(&s).is_err() {
64 // @cpt-begin:cpt-cf-resource-group-algo-sdk-foundation-validate-gts-type-path:p1:inst-gts-val-4a
65 return Err("Invalid GTS type path format".to_owned());
66 // @cpt-end:cpt-cf-resource-group-algo-sdk-foundation-validate-gts-type-path:p1:inst-gts-val-4a
67 }
68 // @cpt-end:cpt-cf-resource-group-algo-sdk-foundation-validate-gts-type-path:p1:inst-gts-val-4
69
70 // @cpt-begin:cpt-cf-resource-group-algo-sdk-foundation-validate-gts-type-path:p1:inst-gts-val-6
71 // @cpt-begin:cpt-cf-resource-group-algo-sdk-foundation-validate-gts-type-path:p1:inst-gts-val-7
72 Ok(Self(s))
73 // @cpt-end:cpt-cf-resource-group-algo-sdk-foundation-validate-gts-type-path:p1:inst-gts-val-7
74 // @cpt-end:cpt-cf-resource-group-algo-sdk-foundation-validate-gts-type-path:p1:inst-gts-val-6
75 }
76
77 /// Return the inner string slice.
78 #[must_use]
79 pub fn as_str(&self) -> &str {
80 &self.0
81 }
82}
83
84impl fmt::Display for GtsTypePath {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 f.write_str(&self.0)
87 }
88}
89
90impl From<GtsTypePath> for String {
91 fn from(p: GtsTypePath) -> Self {
92 p.0
93 }
94}
95
96impl TryFrom<String> for GtsTypePath {
97 type Error = String;
98
99 fn try_from(s: String) -> Result<Self, Self::Error> {
100 Self::new(s)
101 }
102}
103
104impl AsRef<str> for GtsTypePath {
105 fn as_ref(&self) -> &str {
106 &self.0
107 }
108}
109// @cpt-end:cpt-cf-resource-group-algo-sdk-foundation-validate-gts-type-path:p1:inst-gts-val-1
110
111// -- Type --
112
113/// A GTS resource group type definition.
114///
115/// Matches the REST `Type` schema. All references use string GTS type paths;
116/// surrogate SMALLINT IDs are internal to the persistence layer.
117#[derive(Debug, Clone, Default, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct ResourceGroupType {
120 /// GTS type path (e.g. `gts.cf.core.rg.type.v1~cf.core._.tenant.v1~`)
121 pub code: String,
122 /// Whether groups of this type can be root nodes (no parent).
123 pub can_be_root: bool,
124 /// GTS type paths of types allowed as parents.
125 pub allowed_parent_types: Vec<String>,
126 /// GTS type paths of resource types allowed as members.
127 pub allowed_membership_types: Vec<String>,
128 /// Optional JSON Schema for the metadata object of instances of this type.
129 #[serde(skip_serializing_if = "Option::is_none")]
130 pub metadata_schema: Option<serde_json::Value>,
131}
132
133/// Request body for creating a new GTS type.
134#[derive(Debug, Clone, Default, Serialize, Deserialize)]
135#[serde(rename_all = "camelCase")]
136pub struct CreateTypeRequest {
137 /// GTS type path. Must have prefix `gts.cf.core.rg.type.v1~`.
138 ///
139 /// Whether this creates a new tenant scope is derived from the code: any
140 /// type whose path starts with [`TENANT_RG_TYPE_PATH`](crate::TENANT_RG_TYPE_PATH)
141 /// is a tenant type (`tenant_id = group.id` for its instances).
142 pub code: String,
143 /// Whether groups of this type can be root nodes.
144 pub can_be_root: bool,
145 /// GTS type paths of allowed parent types.
146 #[serde(default)]
147 pub allowed_parent_types: Vec<String>,
148 /// GTS type paths of allowed membership resource types.
149 #[serde(default)]
150 pub allowed_membership_types: Vec<String>,
151 /// Optional JSON Schema for instance metadata.
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub metadata_schema: Option<serde_json::Value>,
154}
155
156/// Request body for updating an existing GTS type (full replacement via PUT).
157///
158/// Every replaceable field is **required** so an omitted field cannot be
159/// confused with "preserve previous value". Nullable fields
160/// (`metadata_schema`) must be sent explicitly as `null` to clear them.
161#[derive(Debug, Clone, Default, Serialize, Deserialize)]
162#[serde(rename_all = "camelCase")]
163pub struct UpdateTypeRequest {
164 /// Whether groups of this type can be root nodes.
165 pub can_be_root: bool,
166 /// GTS type paths of allowed parent types.
167 pub allowed_parent_types: Vec<String>,
168 /// GTS type paths of allowed membership resource types.
169 pub allowed_membership_types: Vec<String>,
170 /// JSON Schema for instance metadata (`null` to clear).
171 pub metadata_schema: Option<serde_json::Value>,
172}
173
174// -- Group --
175
176/// Hierarchy context for a resource group.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct GroupHierarchy {
180 /// Parent group ID (null for root groups).
181 #[serde(skip_serializing_if = "Option::is_none")]
182 pub parent_id: Option<Uuid>,
183 /// Tenant scope.
184 pub tenant_id: Uuid,
185}
186
187/// Hierarchy context for a resource group with depth information.
188#[derive(Debug, Clone, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct GroupHierarchyWithDepth {
191 /// Parent group ID (null for root groups).
192 #[serde(skip_serializing_if = "Option::is_none")]
193 pub parent_id: Option<Uuid>,
194 /// Tenant scope.
195 pub tenant_id: Uuid,
196 /// Relative distance from reference group.
197 pub depth: i32,
198}
199
200/// A resource group entity.
201///
202/// Group responses do NOT include `created_at`/`updated_at` (per DESIGN).
203#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(rename_all = "camelCase")]
205pub struct ResourceGroup {
206 /// Group identifier.
207 pub id: Uuid,
208 /// GTS chained type code (e.g. `gts.cf.core.rg.type.v1~cf.core._.tenant.v1~`).
209 #[serde(rename = "type")]
210 pub code: String,
211 /// Display name.
212 pub name: String,
213 /// Hierarchy context.
214 pub hierarchy: GroupHierarchy,
215 /// Type-specific metadata.
216 #[serde(skip_serializing_if = "Option::is_none")]
217 pub metadata: Option<serde_json::Value>,
218}
219
220/// A resource group entity with depth information (for hierarchy queries).
221#[derive(Debug, Clone, Serialize, Deserialize)]
222#[serde(rename_all = "camelCase")]
223pub struct ResourceGroupWithDepth {
224 /// Group identifier.
225 pub id: Uuid,
226 /// GTS chained type code (e.g. `gts.cf.core.rg.type.v1~cf.core._.tenant.v1~`).
227 #[serde(rename = "type")]
228 pub code: String,
229 /// Display name.
230 pub name: String,
231 /// Hierarchy context with depth.
232 pub hierarchy: GroupHierarchyWithDepth,
233 /// Type-specific metadata.
234 #[serde(skip_serializing_if = "Option::is_none")]
235 pub metadata: Option<serde_json::Value>,
236}
237
238/// Request body for creating a new resource group.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240#[serde(rename_all = "camelCase")]
241pub struct CreateGroupRequest {
242 /// Optional caller-supplied ID (used by seeding for stable identity).
243 #[serde(skip_serializing_if = "Option::is_none")]
244 pub id: Option<Uuid>,
245 /// GTS chained type code. Must have prefix `gts.cf.core.rg.type.v1~`.
246 #[serde(rename = "type")]
247 pub code: String,
248 /// Display name (1..255 characters).
249 pub name: String,
250 /// Parent group ID (null for root groups).
251 #[serde(skip_serializing_if = "Option::is_none")]
252 pub parent_id: Option<Uuid>,
253 /// Type-specific metadata.
254 #[serde(skip_serializing_if = "Option::is_none")]
255 pub metadata: Option<serde_json::Value>,
256}
257
258/// Request body for updating a resource group (full replacement via PUT).
259///
260/// **The group's type is immutable after creation.** A group cannot be
261/// converted between tenant-typed and non-tenant-typed (or between any two
262/// distinct GTS types) — the request payload deliberately does not carry a
263/// `type` / `code` field. To change semantics, delete the old group and
264/// create a new one. See `UpdateTypeRequest` for changing the *definition*
265/// of an existing GTS type — that's a different concern.
266#[derive(Debug, Clone, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub struct UpdateGroupRequest {
269 /// Display name (1..255 characters).
270 pub name: String,
271 /// Parent group ID (`null` for root groups). Reparenting is allowed only
272 /// within the same tenant scope; cross-tenant moves are rejected by the
273 /// service layer. Send explicit `null` to move a group to root — an
274 /// omitted key is rejected as a malformed payload.
275 pub parent_id: Option<Uuid>,
276 /// Type-specific metadata (`null` to clear).
277 pub metadata: Option<serde_json::Value>,
278}
279
280// -- Membership --
281
282/// A membership link between a resource and a group.
283#[derive(Debug, Clone, Serialize, Deserialize)]
284#[serde(rename_all = "camelCase")]
285pub struct ResourceGroupMembership {
286 /// Group this resource belongs to.
287 pub group_id: Uuid,
288 /// GTS type path of the resource.
289 pub resource_type: String,
290 /// External resource identifier.
291 pub resource_id: String,
292}
293
294// @cpt-dod:cpt-cf-resource-group-dod-testing-sdk-models:p1
295#[cfg(test)]
296#[path = "models_tests.rs"]
297mod models_tests;
298
299// @cpt-end:cpt-cf-resource-group-dod-sdk-foundation-sdk-models:p1:inst-full