plexus_core/plexus/schema.rs
1/// JSON Schema types with strong typing
2///
3/// This module provides strongly-typed JSON Schema structures that plugins
4/// use to describe their methods and parameters.
5///
6/// Schema generation is fully automatic via schemars. By using proper types
7/// (uuid::Uuid instead of String) and doc comments, schemars generates complete
8/// schemas with format annotations, descriptions, and required arrays.
9
10use schemars::{JsonSchema, schema_for};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14use plexus_auth_core::{
15 AttachmentSite, CredentialFieldMarker, CredentialIssuer, CredentialKind, CredentialMetadata,
16 Scope,
17};
18
19use super::bidirectional::{StandardRequest, StandardResponse};
20
21// =============================================================================
22// Method Role
23// =============================================================================
24
25/// Describes how a method participates in the activation graph.
26///
27/// Every method on a plugin is exactly one of three kinds:
28///
29/// - `Rpc` — a regular RPC endpoint (the default).
30/// - `StaticChild` — the method returns a child activation by a static name
31/// (no lookup argument). Used by `#[child]`-annotated methods on hubs.
32/// - `DynamicChild { .. }` — the method gates a dynamic child keyed by its
33/// argument. `list_method` optionally names a sibling method that enumerates
34/// available keys, and `search_method` optionally names a sibling method
35/// that searches keys.
36///
37/// This tag is consumed by downstream tooling (synapse, synapse-cc,
38/// introspection clients) to reconstruct the child graph without a separate
39/// side-table. Today's macros emit `MethodRole::Rpc` for every method; IR-3
40/// populates child roles from `#[child]` annotations.
41///
42/// # Wire back-compat
43///
44/// Added in IR-2. Serde defaults to `Rpc` for pre-IR schemas.
45/// `#[non_exhaustive]` reserves space for future variants without breaking
46/// downstream match arms.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
48#[serde(tag = "kind", rename_all = "snake_case")]
49#[non_exhaustive]
50pub enum MethodRole {
51 /// Method is an RPC endpoint (the default for ordinary methods).
52 Rpc,
53 /// Method returns a child activation by static name (no lookup arg).
54 StaticChild,
55 /// Method gates a dynamic child keyed by its argument.
56 DynamicChild {
57 /// Optional sibling method that lists available keys.
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 list_method: Option<String>,
60 /// Optional sibling method that searches available keys.
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 search_method: Option<String>,
63 },
64}
65
66impl Default for MethodRole {
67 fn default() -> Self {
68 MethodRole::Rpc
69 }
70}
71
72// =============================================================================
73// Deprecation Info
74// =============================================================================
75
76/// Structured deprecation metadata attached to a `MethodSchema`.
77///
78/// Downstream consumers (CLI help, docs generators, IDEs) use these fields
79/// to surface migration guidance to users.
80///
81/// # Example
82///
83/// ```
84/// use plexus_core::DeprecationInfo;
85///
86/// let info = DeprecationInfo {
87/// since: "0.5".into(),
88/// removed_in: "0.6".into(),
89/// message: "Use `new_method` instead.".into(),
90/// };
91/// ```
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
93pub struct DeprecationInfo {
94 /// The plexus-core version at which deprecation began (e.g., `"0.5"`).
95 pub since: String,
96 /// The plexus-core version planned for removal (e.g., `"0.6"`).
97 ///
98 /// Not binding — serves as a consumer-visible hint.
99 pub removed_in: String,
100 /// Human-readable migration guidance.
101 pub message: String,
102}
103
104// =============================================================================
105// Param Schema
106// =============================================================================
107
108/// Per-parameter metadata for a method's parameters.
109///
110/// `MethodSchema.params` already carries the fine-grained JSON Schema for the
111/// combined parameter object. `ParamSchema` carries orthogonal, parameter-
112/// scoped metadata that doesn't fit on a JSON Schema node — currently just
113/// deprecation info (IR-5).
114///
115/// The `name` field matches the parameter identifier in the method signature
116/// so consumers can correlate entries against the `params` JSON Schema's
117/// `properties` map.
118///
119/// Added in IR-5. Defaults to an empty list on `MethodSchema` so pre-IR
120/// schemas deserialize cleanly.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
122pub struct ParamSchema {
123 /// Parameter name, matching the identifier in the method signature.
124 pub name: String,
125 /// If set, this parameter is deprecated.
126 ///
127 /// Populated by `#[deprecated(...)]` (+ optional
128 /// `#[plexus_macros::removed_in("...")]`) on the parameter in the
129 /// method signature (IR-5).
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub deprecation: Option<DeprecationInfo>,
132}
133
134impl ParamSchema {
135 /// Create a new `ParamSchema` carrying just a name and no metadata.
136 pub fn new(name: impl Into<String>) -> Self {
137 Self {
138 name: name.into(),
139 deprecation: None,
140 }
141 }
142
143 /// Attach deprecation metadata for this parameter.
144 pub fn with_deprecation(mut self, info: DeprecationInfo) -> Self {
145 self.deprecation = Some(info);
146 self
147 }
148}
149
150// =============================================================================
151// Credential projections (AUTHZ-CRED-CORE-3)
152// =============================================================================
153
154/// One entry per credential-bearing field in a method's return type.
155///
156/// Pinned by `AUTHZ-CRED-S01-output.md` §4 and `AUTHZ-CRED-CORE-3` §"Required
157/// behavior". Projected onto `MethodSchema.credentials` at schema-build time
158/// from the `CredentialFieldMarker` registry the `#[derive(Credentials)]` macro
159/// emits per credential-bearing type (`AUTHZ-CRED-MACRO-1`).
160///
161/// # Field path semantics (Tier B Q-IR-1)
162///
163/// `field_path` is the JSON-object path to the credential field within the
164/// method's return type. v1 always uses object-field paths (one segment per
165/// field name walked); array indices and JSON Pointer syntax are intentionally
166/// excluded because credentials always live on object fields in v1, never
167/// inside array elements.
168///
169/// Example: for a return type
170/// `struct LoginResult { session: Credential<String> }`, the field path is
171/// `["session"]`. For a nested case
172/// `struct LoginResult { auth: AuthBundle }` where `AuthBundle` itself
173/// declares a `#[plexus::credential(..)]` field `token: Credential<String>`,
174/// the field path is `["auth", "token"]`.
175///
176/// # Variant tagging
177///
178/// `variant_tag` is `Some(tag)` when the method's return type is an enum and
179/// the credential lives on a single variant. The tag matches the variant
180/// identifier as the macro registry records it (e.g. `"Issued"` for the
181/// `LoginEvent::Issued` variant). `None` means the return type is a struct,
182/// or the credential appears on every variant of the enum (rare).
183///
184/// # Wire back-compat
185///
186/// Added in `AUTHZ-CRED-CORE-3`. Pre-existing readers tolerate `credentials:
187/// []` (the default on `MethodSchema` when no credentials are declared) and
188/// pre-existing IRs that omit the field altogether decode cleanly via
189/// `#[serde(default)]` on the field site.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
191pub struct CredentialFieldDecl {
192 /// JSON-object path to the credential field within the method's return
193 /// type. One segment per field-name walk; pinned to object paths (no
194 /// array indices, no JSON Pointer) per Tier B Q-IR-1.
195 pub field_path: Vec<String>,
196
197 /// Variant tag when the return type is an enum and the credential lives
198 /// on a single variant. `None` for structs.
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub variant_tag: Option<String>,
201
202 /// The credential's metadata (kind, attach site, scheme, scopes, expiry,
203 /// refresh/revoke hints, issuer, sensitivity). Carried verbatim so
204 /// consumers embed identical storage-and-attach logic to the runtime.
205 pub metadata: CredentialMetadata,
206}
207
208impl CredentialFieldDecl {
209 /// Construct a `CredentialFieldDecl` by composing a `CredentialFieldMarker`
210 /// from the macro-emitted registry with the runtime-supplied pieces
211 /// (`expires_at` known only at mint time; `issuer` known at schema-build
212 /// time from the originating method's `(Origin, MethodPath)`).
213 ///
214 /// `path_prefix` is the field-path walk to the marker's parent type — for
215 /// a flat struct it is empty; for a nested case where this marker lives
216 /// inside a field of the method's return type the prefix is the path to
217 /// that wrapping field. The marker's own `field` and `variant` are
218 /// appended.
219 pub fn from_marker(
220 marker: &CredentialFieldMarker,
221 path_prefix: &[&str],
222 issuer: CredentialIssuer,
223 ) -> Self {
224 let mut field_path: Vec<String> = path_prefix.iter().map(|s| (*s).to_owned()).collect();
225 field_path.push(marker.field.to_owned());
226 let variant_tag = marker.variant.map(|v| v.to_owned());
227 let metadata = marker.to_metadata(None, issuer);
228 Self {
229 field_path,
230 variant_tag,
231 metadata,
232 }
233 }
234}
235
236/// What a method requires on input — the implicit-derivation projection of
237/// scope tagging and credential-graph linkage onto a per-method filter.
238///
239/// Pinned by `AUTHZ-CRED-S01-output.md` §4 (Q-SELECT-1 resolution: implicit
240/// derivation from scope tagging plus refresh/revoke linkage) and
241/// `AUTHZ-CRED-CORE-3` §"Implicit derivation". This ticket explicitly does
242/// NOT add a `#[plexus::method(requires_credential = { .. })]` attribute
243/// surface — the proposal from `AUTHZ-CRED-S01-output` §10 is superseded by
244/// the implicit-derivation approach pinned here.
245///
246/// # Matching semantics (consumer-side)
247///
248/// A candidate credential matches this `RequiredCredential` iff:
249/// 1. `kind`: if `Some(k)`, the candidate's `CredentialMetadata.kind`
250/// matches `k` (or the kind-subsumption table per `AUTHZ-CRED-CORE-1`
251/// accepts the substitution; e.g. `OauthAccess <: Bearer`). If `None`,
252/// any kind whose scope set matches is acceptable.
253/// 2. `scopes`: each scope in this set must be wildcard-matched by the
254/// candidate's `CredentialMetadata.scopes`. The wildcard rules belong to
255/// the `Scope` type itself.
256/// 3. `site_hint`: when populated, prefers the candidate whose
257/// `CredentialMetadata.attach_as` equals the hint. Advisory only — a
258/// candidate without the hint is not rejected.
259///
260/// # Wire back-compat
261///
262/// Added in `AUTHZ-CRED-CORE-3`. Pre-existing readers tolerate
263/// `requires_credential: null` (omitted on the wire when `None`) and
264/// pre-existing IRs that omit the field altogether decode cleanly via
265/// `#[serde(default)]` on the field site.
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
267pub struct RequiredCredential {
268 /// Specific kind a candidate credential must have (e.g.,
269 /// `OauthRefresh`), or `None` for "any kind whose scope set matches".
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub kind: Option<CredentialKind>,
272
273 /// Required scope set. A candidate credential's metadata scopes must
274 /// wildcard-match each scope in this set.
275 #[serde(default, skip_serializing_if = "Vec::is_empty")]
276 pub scopes: Vec<Scope>,
277
278 /// Preferred attach site for the client to use when the candidate
279 /// credential has multiple alternates. Advisory only.
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub site_hint: Option<AttachmentSite>,
282}
283
284impl RequiredCredential {
285 /// Derive a `RequiredCredential` from a method's required scope tagging
286 /// (per `AUTHZ-S01-output` §4). The `kind` field is left `None` — any
287 /// kind whose scope set wildcard-matches `scope` is acceptable.
288 ///
289 /// Used when a method declares `#[plexus::method(scope = "...")]` (or
290 /// the framework derives an implicit scope from the method's path).
291 pub fn from_method_scope(scope: Scope) -> Self {
292 Self {
293 kind: None,
294 scopes: vec![scope],
295 site_hint: None,
296 }
297 }
298
299 /// Derive a `RequiredCredential` for a method that appears as the target
300 /// of another credential's `metadata.refresh_via` or `metadata.revoke_via`
301 /// (per `AUTHZ-CRED-CORE-3` §"Implicit derivation" row 3). The `kind`
302 /// field narrows to the issuing credential's kind so the selection step
303 /// picks the right kind (e.g., `OauthRefresh` for the
304 /// `auth.refresh` call on an `OauthAccess` credential per the OAuth
305 /// flow described in `AUTHZ-CRED-S01-output` §7.2).
306 pub fn from_refresh_revoke_target(kind: CredentialKind, scopes: Vec<Scope>) -> Self {
307 Self {
308 kind: Some(kind),
309 scopes,
310 site_hint: None,
311 }
312 }
313}
314
315// =============================================================================
316// Return Shape
317// =============================================================================
318
319/// Describes the structural shape of a method's return type.
320///
321/// Orthogonal to the fine-grained JSON Schema stored in `MethodSchema.returns`:
322/// that schema describes the inner type; this tag describes the wrapping.
323///
324/// - `Bare` — `T`
325/// - `Option` — `Option<T>`
326/// - `Result` — `Result<T, E>`
327/// - `Vec` — `Vec<T>`
328/// - `Stream` — a stream of `T` (e.g., `AsyncGenerator<T>`)
329/// - `ResultOption` — `Result<Option<T>, E>`
330///
331/// Added in IR-2 as an optional, additive field on `MethodSchema`. Consumers
332/// that don't care can ignore it; those generating language bindings use it to
333/// pick the right idiom (e.g., TypeScript `T | null` for `Option`).
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
335#[serde(rename_all = "snake_case")]
336#[non_exhaustive]
337pub enum ReturnShape {
338 /// `T` — the return type is used as-is.
339 Bare,
340 /// `Option<T>` — the return may be null/absent.
341 Option,
342 /// `Result<T, E>` — the return may be an error.
343 Result,
344 /// `Vec<T>` — the return is a list.
345 Vec,
346 /// A stream of `T` events.
347 Stream,
348 /// `Result<Option<T>, E>` — common pattern for fallible lookups.
349 ResultOption,
350}
351
352// =============================================================================
353// HTTP Method Enum
354// =============================================================================
355
356/// HTTP method for REST endpoint routing
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
358#[serde(rename_all = "UPPERCASE")]
359pub enum HttpMethod {
360 /// GET: Idempotent read operations with no side effects
361 Get,
362 /// POST: Create operations or non-idempotent actions (default)
363 Post,
364 /// PUT: Replace/update operations (idempotent)
365 Put,
366 /// DELETE: Remove operations (idempotent)
367 Delete,
368 /// PATCH: Partial update operations
369 Patch,
370}
371
372impl Default for HttpMethod {
373 fn default() -> Self {
374 HttpMethod::Post
375 }
376}
377
378impl HttpMethod {
379 /// Parse from string (case-insensitive)
380 pub fn from_str(s: &str) -> Option<Self> {
381 match s.to_uppercase().as_str() {
382 "GET" => Some(HttpMethod::Get),
383 "POST" => Some(HttpMethod::Post),
384 "PUT" => Some(HttpMethod::Put),
385 "DELETE" => Some(HttpMethod::Delete),
386 "PATCH" => Some(HttpMethod::Patch),
387 _ => None,
388 }
389 }
390
391 /// Convert to uppercase string
392 pub fn as_str(&self) -> &'static str {
393 match self {
394 HttpMethod::Get => "GET",
395 HttpMethod::Post => "POST",
396 HttpMethod::Put => "PUT",
397 HttpMethod::Delete => "DELETE",
398 HttpMethod::Patch => "PATCH",
399 }
400 }
401}
402
403// ============================================================================
404// Plugin Schema
405// ============================================================================
406
407/// A plugin's schema with methods and child summaries.
408///
409/// Children are represented as summaries (namespace, description, hash) rather
410/// than full recursive schemas. This enables lazy traversal - clients can fetch
411/// child schemas individually via `{namespace}.schema`.
412///
413/// - Leaf plugins have `children = None`
414/// - Hub plugins have `children = Some([ChildSummary, ...])`
415#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
416pub struct PluginSchema {
417 /// The plugin's namespace (e.g., "echo", "plexus")
418 pub namespace: String,
419
420 /// The plugin's version (e.g., "1.0.0")
421 pub version: String,
422
423 /// Short description of the plugin (max 15 words)
424 pub description: String,
425
426 /// Detailed description of the plugin (optional)
427 #[serde(skip_serializing_if = "Option::is_none")]
428 pub long_description: Option<String>,
429
430 /// Hash of ONLY this plugin's methods (ignores children)
431 /// Changes when method signatures, names, or descriptions change
432 pub self_hash: String,
433
434 /// Hash of ONLY child plugin hashes (None for leaf plugins)
435 /// Changes when any child's hash changes (recursively)
436 #[serde(skip_serializing_if = "Option::is_none")]
437 pub children_hash: Option<String>,
438
439 /// Composite hash = hash(self_hash + children_hash)
440 /// Use this if you want a single hash for the entire subtree
441 /// Backward compatible with previous single-hash system
442 pub hash: String,
443
444 /// Methods exposed by this plugin
445 pub methods: Vec<MethodSchema>,
446
447 /// Child plugin summaries (None = leaf plugin, Some = hub plugin)
448 ///
449 /// # Deprecated (IR-4)
450 ///
451 /// This side-table is deterministically derived from the method list's
452 /// `MethodRole` tags (one `ChildSummary` per non-`Rpc` method). It stays
453 /// on the wire for back-compat during the 0.5 transition window and is
454 /// slated for removal in 0.6.
455 ///
456 /// Consumers reading child metadata should switch to iterating
457 /// `methods` and filtering by `role != MethodRole::Rpc`. The name field
458 /// on each `MethodSchema` is the child's namespace.
459 #[serde(skip_serializing_if = "Option::is_none")]
460 #[deprecated(
461 since = "0.5",
462 note = "Derive from MethodRole on MethodSchema. Field will be removed in 0.7."
463 )]
464 pub children: Option<Vec<ChildSummary>>,
465
466 /// JSON Schema for the HTTP request type this activation extracts from incoming connections.
467 ///
468 /// Present when the activation declares `request = MyRequest` in `#[plexus::activation(...)]`.
469 /// The schema includes `x-plexus-source` extension fields on each property describing
470 /// where each field is sourced from (cookie, header, query param, peer address, etc.).
471 ///
472 /// Clients can use this to understand what request data the activation expects and
473 /// to generate appropriate authentication/context documentation.
474 #[serde(skip_serializing_if = "Option::is_none")]
475 pub request: Option<serde_json::Value>,
476
477 /// If set, this whole activation is deprecated.
478 ///
479 /// Added in IR-5. Defaults to `None` via `#[serde(default)]` so pre-IR
480 /// schemas deserialize cleanly.
481 ///
482 /// Populated by the `#[deprecated(...)]` attribute on the `impl
483 /// Activation for Foo` block (IR-5).
484 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub deprecation: Option<DeprecationInfo>,
486}
487
488/// Result of a schema query - either full plugin or single method
489#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
490#[serde(untagged)]
491pub enum SchemaResult {
492 /// Full plugin schema (when no method specified)
493 Plugin(PluginSchema),
494 /// Single method schema (when method specified)
495 Method(MethodSchema),
496}
497
498/// Schema for a single method exposed by a plugin
499#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
500pub struct MethodSchema {
501 /// Method name (e.g., "echo", "check")
502 pub name: String,
503
504 /// Human-readable description of what this method does
505 pub description: String,
506
507 /// Content hash of the method definition (for cache invalidation)
508 /// Generated by hashing the method signature within hub-macro
509 pub hash: String,
510
511 /// JSON Schema for the method's parameters (None if no params)
512 #[serde(skip_serializing_if = "Option::is_none")]
513 pub params: Option<schemars::Schema>,
514
515 /// JSON Schema for the method's return type (None if not specified)
516 #[serde(skip_serializing_if = "Option::is_none")]
517 pub returns: Option<schemars::Schema>,
518
519 /// Whether this method streams multiple events (true) or returns a single result (false)
520 ///
521 /// - `streaming: true` → returns `AsyncGenerator<T>` (multiple events)
522 /// - `streaming: false` → returns `Promise<T>` (single event, collected)
523 ///
524 /// All methods use the same streaming protocol under the hood, but this flag
525 /// tells clients how to present the result.
526 #[serde(default)]
527 pub streaming: bool,
528
529 /// Whether this method supports bidirectional communication
530 ///
531 /// When true, the server can send requests to the client during method execution
532 /// and wait for responses (e.g., confirmations, prompts, selections).
533 #[serde(default)]
534 pub bidirectional: bool,
535
536 /// HTTP method for REST endpoints (GET, POST, PUT, DELETE, PATCH)
537 ///
538 /// This field is used by the HTTP gateway to determine which HTTP method
539 /// to use when exposing this method as a REST endpoint. Defaults to POST
540 /// for backward compatibility.
541 ///
542 /// - GET: Idempotent read operations (no side effects)
543 /// - POST: Create operations or non-idempotent actions (default)
544 /// - PUT: Replace/update operations (idempotent)
545 /// - DELETE: Remove operations (idempotent)
546 /// - PATCH: Partial update operations
547 #[serde(default)]
548 pub http_method: HttpMethod,
549
550 /// JSON Schema for the request type sent from server to client
551 ///
552 /// Only relevant when `bidirectional: true`. Describes the structure of
553 /// requests the server may send during method execution.
554 #[serde(skip_serializing_if = "Option::is_none")]
555 pub request_type: Option<schemars::Schema>,
556
557 /// JSON Schema for the response type sent from client to server
558 ///
559 /// Only relevant when `bidirectional: true`. Describes the structure of
560 /// responses the client should send in reply to server requests.
561 #[serde(skip_serializing_if = "Option::is_none")]
562 pub response_type: Option<schemars::Schema>,
563
564 /// How this method participates in the activation graph.
565 ///
566 /// Added in IR-2. Defaults to `MethodRole::Rpc` via `#[serde(default)]`
567 /// so pre-IR schemas deserialize cleanly.
568 ///
569 /// Populated by the `#[plexus::method]` / `#[child]` macros (IR-3).
570 #[serde(default)]
571 pub role: MethodRole,
572
573 /// If set, this method is deprecated.
574 ///
575 /// Added in IR-2. Defaults to `None` via `#[serde(default)]` so pre-IR
576 /// schemas deserialize cleanly.
577 ///
578 /// Populated by the `#[deprecated(...)]` attribute on the underlying
579 /// method (IR-5).
580 #[serde(default, skip_serializing_if = "Option::is_none")]
581 pub deprecation: Option<DeprecationInfo>,
582
583 /// Structural shape of the method's return type (e.g., `Option`, `Vec`,
584 /// `Stream`).
585 ///
586 /// Orthogonal to `returns`, which holds the fine-grained JSON Schema of
587 /// the inner type. Added in IR-2 as an optional, additive field. `None`
588 /// means "not populated" (the wire format supports pre-IR schemas that
589 /// omit this field entirely).
590 #[serde(default, skip_serializing_if = "Option::is_none")]
591 pub return_shape: Option<ReturnShape>,
592
593 /// Per-parameter metadata (currently just deprecation).
594 ///
595 /// Added in IR-5. Defaults to an empty vec via `#[serde(default)]` so
596 /// pre-IR schemas deserialize cleanly. Only parameters that carry
597 /// metadata appear in this list — absence means "no metadata" for that
598 /// parameter, not a bug.
599 ///
600 /// Populated by the `#[deprecated(...)]` attribute on individual
601 /// parameters (IR-5).
602 #[serde(default, skip_serializing_if = "Vec::is_empty")]
603 pub params_meta: Vec<ParamSchema>,
604
605 /// Credential-bearing fields in this method's return type.
606 ///
607 /// One entry per `#[plexus::credential(...)]`-annotated field on the
608 /// return-type struct/enum, in stable declaration order. Empty when
609 /// the return type contains no credentials.
610 ///
611 /// Added in `AUTHZ-CRED-CORE-3`. Defaults to an empty vec via
612 /// `#[serde(default)]` so pre-IR schemas deserialize cleanly. The
613 /// `skip_serializing_if = "Vec::is_empty"` clause keeps the wire JSON
614 /// shape unchanged for methods with no credential-bearing fields
615 /// (back-compat per the ticket's "Wire-format back-compat" table).
616 ///
617 /// Populated at schema-build time from the `CredentialFieldMarker`
618 /// registry emitted by `#[derive(Credentials)]` (`AUTHZ-CRED-MACRO-1`).
619 #[serde(default, skip_serializing_if = "Vec::is_empty")]
620 pub credentials: Vec<CredentialFieldDecl>,
621
622 /// What credential this method requires on input (if any).
623 ///
624 /// Derived implicitly from the method's scope tagging and the
625 /// credential-graph linkage at schema-build time (per
626 /// `AUTHZ-CRED-CORE-3` §"Implicit derivation"):
627 ///
628 /// - `scope = "..."` → `RequiredCredential::from_method_scope(scope)`.
629 /// - `public` method → `None` (the absence of this field).
630 /// - target of `refresh_via`/`revoke_via` of another credential →
631 /// `RequiredCredential::from_refresh_revoke_target(kind, scopes)`.
632 ///
633 /// Added in `AUTHZ-CRED-CORE-3`. Defaults to `None` via
634 /// `#[serde(default)]` so pre-IR schemas deserialize cleanly. The
635 /// `skip_serializing_if = "Option::is_none"` clause keeps the wire JSON
636 /// shape unchanged for public methods and methods with no scope-derived
637 /// requirement.
638 #[serde(default, skip_serializing_if = "Option::is_none")]
639 pub requires_credential: Option<RequiredCredential>,
640}
641
642impl PluginSchema {
643 /// Compute all three hashes (self, children, composite)
644 fn compute_hashes(
645 methods: &[MethodSchema],
646 children: Option<&[ChildSummary]>,
647 ) -> (String, Option<String>, String) {
648 use std::collections::hash_map::DefaultHasher;
649 use std::hash::{Hash, Hasher};
650
651 // Compute self_hash (methods only)
652 let mut self_hasher = DefaultHasher::new();
653 for m in methods {
654 m.hash.hash(&mut self_hasher);
655 }
656 let self_hash = format!("{:016x}", self_hasher.finish());
657
658 // Compute children_hash (children only)
659 let children_hash = children.map(|kids| {
660 let mut children_hasher = DefaultHasher::new();
661 for c in kids {
662 c.hash.hash(&mut children_hasher);
663 }
664 format!("{:016x}", children_hasher.finish())
665 });
666
667 // Compute composite hash (both)
668 let mut composite_hasher = DefaultHasher::new();
669 self_hash.hash(&mut composite_hasher);
670 if let Some(ref ch) = children_hash {
671 ch.hash(&mut composite_hasher);
672 }
673 let hash = format!("{:016x}", composite_hasher.finish());
674
675 (self_hash, children_hash, hash)
676 }
677
678 /// Inspect each method's `returns` JSON Schema for the framework-
679 /// reserved `_credentials` top-level field name; emit a tracing
680 /// warning when a collision is detected (AUTHZ-CRED-CORE-2
681 /// acceptance criterion #8). Returns the number of collisions found,
682 /// so callers can chain into a metrics counter or assertion if they
683 /// want one.
684 ///
685 /// The framework reserves the `_credentials` top-level field name
686 /// for the credential sidecar (see
687 /// `crate::plexus::credential_envelope`). Backends that define a
688 /// domain field of the same name have it shadowed at dispatch time;
689 /// the warning surfaces that fact at build time so backends can
690 /// rename.
691 fn warn_on_credentials_field_collisions(
692 namespace: &str,
693 methods: &[MethodSchema],
694 ) -> usize {
695 let mut count = 0;
696 for m in methods {
697 let Some(returns_schema) = &m.returns else { continue };
698 let Ok(returns_json) = serde_json::to_value(returns_schema) else { continue };
699 if super::credential_envelope::check_returns_schema_for_credentials_collision(
700 namespace,
701 &m.name,
702 &returns_json,
703 ) {
704 count += 1;
705 }
706 }
707 count
708 }
709
710 /// Validate no name collisions exist within a plugin
711 ///
712 /// Checks for:
713 /// - Duplicate method names
714 /// - Duplicate child names (for hubs)
715 /// - Method/child name collisions for `Rpc`-role methods (for hubs)
716 ///
717 /// Panics if a collision is detected (system error).
718 ///
719 /// # IR-4 relaxation
720 ///
721 /// As of IR-4, a method with `MethodRole::StaticChild` or
722 /// `MethodRole::DynamicChild { .. }` that shares a name with a
723 /// `ChildSummary` entry is **not** a collision — it's the same child
724 /// surfaced via two wire representations (the role-tagged method list
725 /// and the deprecated `children` side-table). Only `Rpc`-role methods
726 /// whose name matches a child summary are flagged.
727 fn validate_no_collisions(
728 namespace: &str,
729 methods: &[MethodSchema],
730 children: Option<&[ChildSummary]>,
731 ) {
732 use std::collections::HashSet;
733
734 let mut seen: HashSet<&str> = HashSet::new();
735
736 // Check method names
737 for m in methods {
738 if !seen.insert(&m.name) {
739 panic!(
740 "Name collision in plugin '{}': duplicate method '{}'",
741 namespace, m.name
742 );
743 }
744 }
745
746 // Check child names (and collisions with methods)
747 if let Some(kids) = children {
748 for c in kids {
749 if !seen.insert(&c.namespace) {
750 // IR-4: a role-tagged child method whose name matches a
751 // child summary is expected by construction (the two
752 // wire-surfaces describe the same child). Skip silently.
753 let colliding_method =
754 methods.iter().find(|m| m.name == c.namespace);
755 if let Some(m) = colliding_method {
756 if matches!(
757 m.role,
758 MethodRole::StaticChild | MethodRole::DynamicChild { .. }
759 ) {
760 continue;
761 }
762 }
763 // Could be duplicate child or collision with an Rpc-role method
764 let collision_type = if colliding_method.is_some() {
765 "method/child collision"
766 } else {
767 "duplicate child"
768 };
769 panic!(
770 "Name collision in plugin '{}': {} for '{}'",
771 namespace, collision_type, c.namespace
772 );
773 }
774 }
775 }
776 }
777
778 /// Derive the deprecated `(children, is_hub)` side-table fields from a
779 /// role-tagged method list.
780 ///
781 /// Added in IR-4 as the **centralized shim** that backfills the
782 /// pre-IR `children: Option<Vec<ChildSummary>>` and `is_hub: bool`
783 /// representations from the authoritative `MethodRole` on each
784 /// `MethodSchema`.
785 ///
786 /// # Semantics
787 ///
788 /// One `ChildSummary` is produced per non-`Rpc` method, preserving the
789 /// source order. The shim writes:
790 ///
791 /// | Field | Value |
792 /// |---|---|
793 /// | `namespace` | The method's name. |
794 /// | `description` | The method's `description`. |
795 /// | `hash` | Empty string — the shim does **not** compute child hashes. Callers that want per-child hashes must populate them out-of-band. |
796 ///
797 /// The returned `bool` matches [`PluginSchema::is_hub_by_role`] — `true`
798 /// iff at least one method carries a child role.
799 ///
800 /// # Example
801 ///
802 /// ```
803 /// use plexus_core::plexus::schema::{MethodRole, MethodSchema, PluginSchema};
804 ///
805 /// let methods = vec![
806 /// MethodSchema::new("ping", "rpc", "h1"),
807 /// MethodSchema::new("kid", "static child", "h2")
808 /// .with_role(MethodRole::StaticChild),
809 /// ];
810 /// let (children, is_hub) = PluginSchema::derive_legacy_fields(&methods);
811 /// assert_eq!(children.len(), 1);
812 /// assert_eq!(children[0].namespace, "kid");
813 /// assert!(is_hub);
814 /// ```
815 pub fn derive_legacy_fields(
816 methods: &[MethodSchema],
817 ) -> (Vec<ChildSummary>, bool) {
818 let children: Vec<ChildSummary> = methods
819 .iter()
820 .filter(|m| {
821 matches!(
822 m.role,
823 MethodRole::StaticChild | MethodRole::DynamicChild { .. }
824 )
825 })
826 .map(|m| ChildSummary {
827 namespace: m.name.clone(),
828 description: m.description.clone(),
829 hash: String::new(),
830 })
831 .collect();
832 let is_hub = !children.is_empty();
833 (children, is_hub)
834 }
835
836 /// Create a new leaf plugin schema (no children)
837 #[allow(deprecated)]
838 pub fn leaf(
839 namespace: impl Into<String>,
840 version: impl Into<String>,
841 description: impl Into<String>,
842 methods: Vec<MethodSchema>,
843 ) -> Self {
844 let namespace = namespace.into();
845 Self::validate_no_collisions(&namespace, &methods, None);
846 // AUTHZ-CRED-CORE-2 acceptance criterion #8: warn at schema-build
847 // time when a method's return-type schema declares a top-level
848 // field named `_credentials` (reserved by the framework's
849 // credential sidecar).
850 let _ = Self::warn_on_credentials_field_collisions(&namespace, &methods);
851 let (self_hash, children_hash, hash) = Self::compute_hashes(&methods, None);
852 Self {
853 namespace,
854 version: version.into(),
855 description: description.into(),
856 long_description: None,
857 self_hash,
858 children_hash,
859 hash,
860 methods,
861 children: None,
862 request: None,
863 deprecation: None,
864 }
865 }
866
867 /// Create a new leaf plugin schema with long description
868 #[allow(deprecated)]
869 pub fn leaf_with_long_description(
870 namespace: impl Into<String>,
871 version: impl Into<String>,
872 description: impl Into<String>,
873 long_description: impl Into<String>,
874 methods: Vec<MethodSchema>,
875 ) -> Self {
876 let namespace = namespace.into();
877 Self::validate_no_collisions(&namespace, &methods, None);
878 // AUTHZ-CRED-CORE-2 AC #8: see PluginSchema::leaf.
879 let _ = Self::warn_on_credentials_field_collisions(&namespace, &methods);
880 let (self_hash, children_hash, hash) = Self::compute_hashes(&methods, None);
881 Self {
882 namespace,
883 version: version.into(),
884 description: description.into(),
885 long_description: Some(long_description.into()),
886 self_hash,
887 children_hash,
888 hash,
889 methods,
890 children: None,
891 request: None,
892 deprecation: None,
893 }
894 }
895
896 /// Create a new hub plugin schema (with child summaries)
897 #[allow(deprecated)]
898 pub fn hub(
899 namespace: impl Into<String>,
900 version: impl Into<String>,
901 description: impl Into<String>,
902 methods: Vec<MethodSchema>,
903 children: Vec<ChildSummary>,
904 ) -> Self {
905 let namespace = namespace.into();
906 Self::validate_no_collisions(&namespace, &methods, Some(&children));
907 // AUTHZ-CRED-CORE-2 AC #8: see PluginSchema::leaf.
908 let _ = Self::warn_on_credentials_field_collisions(&namespace, &methods);
909 let (self_hash, children_hash, hash) = Self::compute_hashes(&methods, Some(&children));
910 Self {
911 namespace,
912 version: version.into(),
913 description: description.into(),
914 long_description: None,
915 self_hash,
916 children_hash,
917 hash,
918 methods,
919 children: Some(children),
920 request: None,
921 deprecation: None,
922 }
923 }
924
925 /// Create a new hub plugin schema with long description
926 #[allow(deprecated)]
927 pub fn hub_with_long_description(
928 namespace: impl Into<String>,
929 version: impl Into<String>,
930 description: impl Into<String>,
931 long_description: impl Into<String>,
932 methods: Vec<MethodSchema>,
933 children: Vec<ChildSummary>,
934 ) -> Self {
935 let namespace = namespace.into();
936 Self::validate_no_collisions(&namespace, &methods, Some(&children));
937 // AUTHZ-CRED-CORE-2 AC #8: see PluginSchema::leaf.
938 let _ = Self::warn_on_credentials_field_collisions(&namespace, &methods);
939 let (self_hash, children_hash, hash) = Self::compute_hashes(&methods, Some(&children));
940 Self {
941 namespace,
942 version: version.into(),
943 description: description.into(),
944 long_description: Some(long_description.into()),
945 self_hash,
946 children_hash,
947 hash,
948 methods,
949 children: Some(children),
950 request: None,
951 deprecation: None,
952 }
953 }
954
955 /// Check if this is a hub.
956 ///
957 /// Returns `true` iff the plugin exposes child activations. As of IR-2,
958 /// this is derived from **either** source of truth:
959 ///
960 /// 1. Any method tagged with a child `MethodRole` (`StaticChild` or
961 /// `DynamicChild { .. }`). This is the post-IR-3 authoritative signal.
962 /// 2. The legacy `children: Option<Vec<ChildSummary>>` field is `Some`.
963 /// Preserved for back-compat during the IR transition window —
964 /// today's macros populate `children` but not yet `role`.
965 ///
966 /// # Deprecated (IR-4)
967 ///
968 /// The legacy transition-window fallback on `children.is_some()` is
969 /// redundant now that `MethodRole` tags are authoritative. Callers
970 /// should migrate to [`PluginSchema::is_hub_by_role`], which reads
971 /// only role-tagged methods. This method will be removed in 0.7.
972 #[deprecated(
973 since = "0.5",
974 note = "Use `PluginSchema::is_hub_by_role()` which reads MethodRole from methods. This method will be removed in 0.7."
975 )]
976 #[allow(deprecated)]
977 pub fn is_hub(&self) -> bool {
978 self.is_hub_by_role() || self.children.is_some()
979 }
980
981 /// Returns `true` iff any method carries a child `MethodRole`.
982 ///
983 /// This is the **derived query** specified by IR-2: it reads only
984 /// `self.methods`, ignoring the legacy `children` side channel. Use this
985 /// when you want the post-IR-3 authoritative answer without the transition
986 /// fallback that `is_hub()` provides.
987 pub fn is_hub_by_role(&self) -> bool {
988 self.methods.iter().any(|m| {
989 matches!(
990 m.role,
991 MethodRole::StaticChild | MethodRole::DynamicChild { .. }
992 )
993 })
994 }
995
996 /// Check if this is a leaf (no children)
997 #[allow(deprecated)]
998 pub fn is_leaf(&self) -> bool {
999 self.children.is_none()
1000 }
1001
1002 /// Mark this plugin as deprecated.
1003 ///
1004 /// Added in IR-5. Populates the `deprecation` field with the provided
1005 /// `DeprecationInfo`. Populated by the `#[deprecated(...)]` attribute on
1006 /// an `impl Activation for Foo` block via `plexus-macros`.
1007 pub fn with_deprecation(mut self, info: DeprecationInfo) -> Self {
1008 self.deprecation = Some(info);
1009 self
1010 }
1011}
1012
1013/// Summary of a child plugin
1014#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1015pub struct ChildSummary {
1016 /// The child's namespace
1017 pub namespace: String,
1018
1019 /// Human-readable description
1020 pub description: String,
1021
1022 /// Content hash for cache invalidation
1023 pub hash: String,
1024}
1025
1026/// Schema summary containing only hashes (for cache validation)
1027#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1028pub struct PluginHashes {
1029 pub namespace: String,
1030 pub self_hash: String,
1031 #[serde(skip_serializing_if = "Option::is_none")]
1032 pub children_hash: Option<String>,
1033 pub hash: String,
1034 /// Child plugin hashes (for recursive checking)
1035 #[serde(skip_serializing_if = "Option::is_none")]
1036 pub children: Option<Vec<ChildHashes>>,
1037}
1038
1039#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1040pub struct ChildHashes {
1041 pub namespace: String,
1042 pub hash: String,
1043}
1044
1045impl MethodSchema {
1046 /// Create a new method schema with name, description, and hash
1047 ///
1048 /// The hash should be computed from the method definition string
1049 /// within the hub-macro at compile time.
1050 pub fn new(
1051 name: impl Into<String>,
1052 description: impl Into<String>,
1053 hash: impl Into<String>,
1054 ) -> Self {
1055 Self {
1056 name: name.into(),
1057 description: description.into(),
1058 hash: hash.into(),
1059 params: None,
1060 returns: None,
1061 streaming: false,
1062 bidirectional: false,
1063 http_method: HttpMethod::default(),
1064 request_type: None,
1065 response_type: None,
1066 role: MethodRole::Rpc,
1067 deprecation: None,
1068 return_shape: None,
1069 params_meta: Vec::new(),
1070 credentials: Vec::new(),
1071 requires_credential: None,
1072 }
1073 }
1074
1075 /// Add parameter schema
1076 pub fn with_params(mut self, params: schemars::Schema) -> Self {
1077 self.params = Some(params);
1078 self
1079 }
1080
1081 /// Add return type schema
1082 pub fn with_returns(mut self, returns: schemars::Schema) -> Self {
1083 self.returns = Some(returns);
1084 self
1085 }
1086
1087 /// Set the streaming flag
1088 ///
1089 /// - `true` → method streams multiple events (use `AsyncGenerator<T>`)
1090 /// - `false` → method returns single result (use `Promise<T>`)
1091 pub fn with_streaming(mut self, streaming: bool) -> Self {
1092 self.streaming = streaming;
1093 self
1094 }
1095
1096 /// Set the HTTP method for REST endpoints
1097 ///
1098 /// Defaults to POST for backward compatibility.
1099 ///
1100 /// # Guidelines
1101 /// - GET: Idempotent read operations with no side effects
1102 /// - POST: Create operations or non-idempotent actions
1103 /// - PUT: Replace/update operations (idempotent)
1104 /// - DELETE: Remove operations (idempotent)
1105 /// - PATCH: Partial update operations
1106 pub fn with_http_method(mut self, http_method: HttpMethod) -> Self {
1107 self.http_method = http_method;
1108 self
1109 }
1110
1111 /// Set whether this method supports bidirectional communication
1112 ///
1113 /// When true, the server can send requests to the client during method
1114 /// execution and wait for responses.
1115 pub fn with_bidirectional(mut self, bidirectional: bool) -> Self {
1116 self.bidirectional = bidirectional;
1117 self
1118 }
1119
1120 /// Set the JSON Schema for server-to-client request types
1121 ///
1122 /// Only relevant when `bidirectional: true`. Use `schema_for!(YourRequestType)`
1123 /// to generate the schema.
1124 pub fn with_request_type(mut self, schema: schemars::Schema) -> Self {
1125 self.request_type = Some(schema);
1126 self
1127 }
1128
1129 /// Set the JSON Schema for client-to-server response types
1130 ///
1131 /// Only relevant when `bidirectional: true`. Use `schema_for!(YourResponseType)`
1132 /// to generate the schema.
1133 pub fn with_response_type(mut self, schema: schemars::Schema) -> Self {
1134 self.response_type = Some(schema);
1135 self
1136 }
1137
1138 /// Configure method for standard bidirectional communication
1139 ///
1140 /// Sets `bidirectional: true` and configures request/response types to use
1141 /// `StandardRequest` and `StandardResponse`, which support common UI patterns
1142 /// like confirmations, prompts, and selections.
1143 pub fn with_standard_bidirectional(self) -> Self {
1144 self.with_bidirectional(true)
1145 .with_request_type(schema_for!(StandardRequest).into())
1146 .with_response_type(schema_for!(StandardResponse).into())
1147 }
1148
1149 /// Set this method's role in the activation graph.
1150 ///
1151 /// Added in IR-2. Defaults to `MethodRole::Rpc`.
1152 pub fn with_role(mut self, role: MethodRole) -> Self {
1153 self.role = role;
1154 self
1155 }
1156
1157 /// Mark this method as deprecated.
1158 ///
1159 /// Added in IR-2. Populates the `deprecation` field with the provided
1160 /// `DeprecationInfo`.
1161 pub fn with_deprecation(mut self, info: DeprecationInfo) -> Self {
1162 self.deprecation = Some(info);
1163 self
1164 }
1165
1166 /// Set the structural shape of this method's return type.
1167 ///
1168 /// Added in IR-2. Orthogonal to `with_returns`, which sets the fine-grained
1169 /// JSON Schema.
1170 pub fn with_return_shape(mut self, shape: ReturnShape) -> Self {
1171 self.return_shape = Some(shape);
1172 self
1173 }
1174
1175 /// Attach per-parameter metadata for this method's parameters.
1176 ///
1177 /// Added in IR-5. Only parameters that carry metadata (e.g. a
1178 /// `#[deprecated]` annotation) need appear in `entries`; absence means
1179 /// "no metadata" for a given parameter. The consumer correlates entries
1180 /// against `self.params` by matching `ParamSchema.name` against the
1181 /// `properties` map of the JSON Schema.
1182 pub fn with_params_meta(mut self, entries: Vec<ParamSchema>) -> Self {
1183 self.params_meta = entries;
1184 self
1185 }
1186
1187 /// Attach the credential-field projection for this method's return type.
1188 ///
1189 /// Added in `AUTHZ-CRED-CORE-3`. The build-path supplies one
1190 /// `CredentialFieldDecl` per `#[plexus::credential(...)]`-annotated
1191 /// field in the return type, in stable declaration order. Absence (the
1192 /// default empty vec) means the return type carries no credentials.
1193 ///
1194 /// The constructor `CredentialFieldDecl::from_marker` composes one
1195 /// entry from a `CredentialFieldMarker` (emitted by the
1196 /// `#[derive(Credentials)]` macro) plus the runtime-known issuer.
1197 pub fn with_credentials(mut self, credentials: Vec<CredentialFieldDecl>) -> Self {
1198 self.credentials = credentials;
1199 self
1200 }
1201
1202 /// Attach the implicit-derived `requires_credential` filter for this
1203 /// method.
1204 ///
1205 /// Added in `AUTHZ-CRED-CORE-3`. The build-path supplies a
1206 /// `RequiredCredential` derived from either (a) the method's `scope`
1207 /// attribute or (b) the method's appearance as a `refresh_via` /
1208 /// `revoke_via` target of some other credential in the schema. Public
1209 /// methods leave this field as `None` (the default).
1210 ///
1211 /// `RequiredCredential::from_method_scope` and
1212 /// `RequiredCredential::from_refresh_revoke_target` are the two derivation
1213 /// entry points.
1214 pub fn with_requires_credential(mut self, req: RequiredCredential) -> Self {
1215 self.requires_credential = Some(req);
1216 self
1217 }
1218}
1219
1220// ============================================================================
1221// JSON Schema Types
1222// ============================================================================
1223
1224/// A complete JSON Schema with metadata
1225#[derive(Debug, Clone, Serialize, Deserialize)]
1226pub struct Schema {
1227 /// The JSON Schema specification version
1228 #[serde(rename = "$schema", skip_serializing_if = "Option::is_none", default)]
1229 pub schema_version: Option<String>,
1230
1231 /// Title of the schema
1232 #[serde(skip_serializing_if = "Option::is_none")]
1233 pub title: Option<String>,
1234
1235 /// Description of what this schema represents
1236 #[serde(skip_serializing_if = "Option::is_none")]
1237 pub description: Option<String>,
1238
1239 /// The schema type (typically "object" for root, can be string or array)
1240 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
1241 pub schema_type: Option<serde_json::Value>,
1242
1243 /// Properties for object types
1244 #[serde(skip_serializing_if = "Option::is_none")]
1245 pub properties: Option<HashMap<String, SchemaProperty>>,
1246
1247 /// Required properties
1248 #[serde(skip_serializing_if = "Option::is_none")]
1249 pub required: Option<Vec<String>>,
1250
1251 /// Enum variants (for discriminated unions)
1252 #[serde(rename = "oneOf", skip_serializing_if = "Option::is_none")]
1253 pub one_of: Option<Vec<Schema>>,
1254
1255 /// Schema definitions (for $defs or definitions)
1256 #[serde(rename = "$defs", skip_serializing_if = "Option::is_none")]
1257 pub defs: Option<HashMap<String, serde_json::Value>>,
1258
1259 /// Any additional schema properties
1260 #[serde(flatten)]
1261 pub additional: HashMap<String, serde_json::Value>,
1262}
1263
1264/// Schema type enumeration
1265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1266#[serde(rename_all = "lowercase")]
1267pub enum SchemaType {
1268 Object,
1269 Array,
1270 String,
1271 Number,
1272 Integer,
1273 Boolean,
1274 Null,
1275}
1276
1277/// A property definition in a schema
1278#[derive(Debug, Clone, Serialize, Deserialize)]
1279pub struct SchemaProperty {
1280 /// The type of this property (can be a single type or array of types for nullable)
1281 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
1282 pub property_type: Option<serde_json::Value>,
1283
1284 /// Description of this property
1285 #[serde(skip_serializing_if = "Option::is_none")]
1286 pub description: Option<String>,
1287
1288 /// Format hint (e.g., "uuid", "date-time", "email")
1289 #[serde(skip_serializing_if = "Option::is_none")]
1290 pub format: Option<String>,
1291
1292 /// For array types, the schema of items
1293 #[serde(skip_serializing_if = "Option::is_none")]
1294 pub items: Option<Box<SchemaProperty>>,
1295
1296 /// For object types, nested properties
1297 #[serde(skip_serializing_if = "Option::is_none")]
1298 pub properties: Option<HashMap<String, SchemaProperty>>,
1299
1300 /// Required properties (for object types)
1301 #[serde(skip_serializing_if = "Option::is_none")]
1302 pub required: Option<Vec<String>>,
1303
1304 /// Default value for this property
1305 #[serde(skip_serializing_if = "Option::is_none")]
1306 pub default: Option<serde_json::Value>,
1307
1308 /// Enum values if this is an enum
1309 #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
1310 pub enum_values: Option<Vec<serde_json::Value>>,
1311
1312 /// Reference to another schema definition
1313 #[serde(rename = "$ref", skip_serializing_if = "Option::is_none")]
1314 pub reference: Option<String>,
1315
1316 /// Any additional property metadata
1317 #[serde(flatten)]
1318 pub additional: HashMap<String, serde_json::Value>,
1319}
1320
1321impl Schema {
1322 /// Create a new schema with basic metadata
1323 pub fn new(title: impl Into<String>, description: impl Into<String>) -> Self {
1324 Self {
1325 schema_version: Some("http://json-schema.org/draft-07/schema#".to_string()),
1326 title: Some(title.into()),
1327 description: Some(description.into()),
1328 schema_type: None,
1329 properties: None,
1330 required: None,
1331 one_of: None,
1332 defs: None,
1333 additional: HashMap::new(),
1334 }
1335 }
1336
1337 /// Create an object schema
1338 pub fn object() -> Self {
1339 Self {
1340 schema_version: Some("http://json-schema.org/draft-07/schema#".to_string()),
1341 title: None,
1342 description: None,
1343 schema_type: Some(serde_json::json!("object")),
1344 properties: Some(HashMap::new()),
1345 required: None,
1346 one_of: None,
1347 defs: None,
1348 additional: HashMap::new(),
1349 }
1350 }
1351
1352 /// Add a property to this schema
1353 pub fn with_property(mut self, name: impl Into<String>, property: SchemaProperty) -> Self {
1354 self.properties
1355 .get_or_insert_with(HashMap::new)
1356 .insert(name.into(), property);
1357 self
1358 }
1359
1360 /// Mark a property as required
1361 pub fn with_required(mut self, name: impl Into<String>) -> Self {
1362 self.required
1363 .get_or_insert_with(Vec::new)
1364 .push(name.into());
1365 self
1366 }
1367
1368 /// Set the description
1369 pub fn with_description(mut self, description: impl Into<String>) -> Self {
1370 self.description = Some(description.into());
1371 self
1372 }
1373
1374 /// Extract a single method's schema from the oneOf array
1375 ///
1376 /// Searches the oneOf variants for a method matching the given name.
1377 /// Returns the variant schema if found, None otherwise.
1378 pub fn get_method_schema(&self, method_name: &str) -> Option<Schema> {
1379 let variants = self.one_of.as_ref()?;
1380
1381 for variant in variants {
1382 // Check if this variant has a "method" property with const or enum
1383 if let Some(props) = &variant.properties {
1384 if let Some(method_prop) = props.get("method") {
1385 // Try "const" first (schemars uses this for literal values)
1386 if let Some(const_val) = method_prop.additional.get("const") {
1387 if const_val.as_str() == Some(method_name) {
1388 return Some(variant.clone());
1389 }
1390 }
1391 // Fall back to enum_values
1392 if let Some(enum_vals) = &method_prop.enum_values {
1393 if enum_vals.first().and_then(|v| v.as_str()) == Some(method_name) {
1394 return Some(variant.clone());
1395 }
1396 }
1397 }
1398 }
1399 }
1400 None
1401 }
1402
1403 /// List all method names from the oneOf array
1404 pub fn list_methods(&self) -> Vec<String> {
1405 let Some(variants) = &self.one_of else {
1406 return Vec::new();
1407 };
1408
1409 variants
1410 .iter()
1411 .filter_map(|variant| {
1412 let props = variant.properties.as_ref()?;
1413 let method_prop = props.get("method")?;
1414
1415 // Try "const" first
1416 if let Some(const_val) = method_prop.additional.get("const") {
1417 return const_val.as_str().map(String::from);
1418 }
1419 // Fall back to enum_values
1420 method_prop
1421 .enum_values
1422 .as_ref()?
1423 .first()?
1424 .as_str()
1425 .map(String::from)
1426 })
1427 .collect()
1428 }
1429}
1430
1431impl SchemaProperty {
1432 /// Create a string property
1433 pub fn string() -> Self {
1434 Self {
1435 property_type: Some(serde_json::json!("string")),
1436 description: None,
1437 format: None,
1438 items: None,
1439 properties: None,
1440 required: None,
1441 default: None,
1442 enum_values: None,
1443 reference: None,
1444 additional: HashMap::new(),
1445 }
1446 }
1447
1448 /// Create a UUID property (string with format)
1449 pub fn uuid() -> Self {
1450 Self {
1451 property_type: Some(serde_json::json!("string")),
1452 description: None,
1453 format: Some("uuid".to_string()),
1454 items: None,
1455 properties: None,
1456 required: None,
1457 default: None,
1458 enum_values: None,
1459 reference: None,
1460 additional: HashMap::new(),
1461 }
1462 }
1463
1464 /// Create an integer property
1465 pub fn integer() -> Self {
1466 Self {
1467 property_type: Some(serde_json::json!("integer")),
1468 description: None,
1469 format: None,
1470 items: None,
1471 properties: None,
1472 required: None,
1473 default: None,
1474 enum_values: None,
1475 reference: None,
1476 additional: HashMap::new(),
1477 }
1478 }
1479
1480 /// Create an object property
1481 pub fn object() -> Self {
1482 Self {
1483 property_type: Some(serde_json::json!("object")),
1484 description: None,
1485 format: None,
1486 items: None,
1487 properties: Some(HashMap::new()),
1488 required: None,
1489 default: None,
1490 enum_values: None,
1491 reference: None,
1492 additional: HashMap::new(),
1493 }
1494 }
1495
1496 /// Create an array property
1497 pub fn array(items: SchemaProperty) -> Self {
1498 Self {
1499 property_type: Some(serde_json::json!("array")),
1500 description: None,
1501 format: None,
1502 items: Some(Box::new(items)),
1503 properties: None,
1504 required: None,
1505 default: None,
1506 enum_values: None,
1507 reference: None,
1508 additional: HashMap::new(),
1509 }
1510 }
1511
1512 /// Add a description
1513 pub fn with_description(mut self, description: impl Into<String>) -> Self {
1514 self.description = Some(description.into());
1515 self
1516 }
1517
1518 /// Add a default value
1519 pub fn with_default(mut self, default: serde_json::Value) -> Self {
1520 self.default = Some(default);
1521 self
1522 }
1523
1524 /// Add nested properties (for object types)
1525 pub fn with_property(mut self, name: impl Into<String>, property: SchemaProperty) -> Self {
1526 self.properties
1527 .get_or_insert_with(HashMap::new)
1528 .insert(name.into(), property);
1529 self
1530 }
1531}
1532
1533#[cfg(test)]
1534#[allow(deprecated)]
1535mod tests {
1536 use super::*;
1537
1538 #[test]
1539 fn test_schema_creation() {
1540 let schema = Schema::object()
1541 .with_property("id", SchemaProperty::uuid().with_description("The unique identifier"))
1542 .with_property("name", SchemaProperty::string().with_description("The name"))
1543 .with_required("id");
1544
1545 assert_eq!(schema.schema_type, Some(serde_json::json!("object")));
1546 assert!(schema.properties.is_some());
1547 assert_eq!(schema.required, Some(vec!["id".to_string()]));
1548 }
1549
1550 #[test]
1551 fn test_serialization() {
1552 let schema = Schema::object()
1553 .with_property("id", SchemaProperty::uuid());
1554
1555 let json = serde_json::to_string_pretty(&schema).unwrap();
1556 assert!(json.contains("uuid"));
1557 }
1558
1559 #[test]
1560 fn test_self_hash_changes_on_method_change() {
1561 let schema1 = PluginSchema::leaf(
1562 "test",
1563 "1.0",
1564 "desc",
1565 vec![MethodSchema::new("foo", "bar", "hash1")],
1566 );
1567
1568 let schema2 = PluginSchema::leaf(
1569 "test",
1570 "1.0",
1571 "desc",
1572 vec![MethodSchema::new("foo", "baz", "hash2")], // Changed description
1573 );
1574
1575 assert_ne!(schema1.self_hash, schema2.self_hash, "self_hash should change when methods change");
1576 assert_eq!(schema1.children_hash, schema2.children_hash, "children_hash should stay same (both None)");
1577 assert_ne!(schema1.hash, schema2.hash, "composite hash should change");
1578 }
1579
1580 #[test]
1581 fn test_children_hash_changes_on_child_change() {
1582 let child1 = ChildSummary {
1583 namespace: "child".into(),
1584 description: "desc".into(),
1585 hash: "old_hash".into(),
1586 };
1587
1588 let child2 = ChildSummary {
1589 namespace: "child".into(),
1590 description: "desc".into(),
1591 hash: "new_hash".into(),
1592 };
1593
1594 let schema1 = PluginSchema::hub(
1595 "parent",
1596 "1.0",
1597 "desc",
1598 vec![],
1599 vec![child1],
1600 );
1601
1602 let schema2 = PluginSchema::hub(
1603 "parent",
1604 "1.0",
1605 "desc",
1606 vec![],
1607 vec![child2],
1608 );
1609
1610 assert_eq!(schema1.self_hash, schema2.self_hash, "self_hash should stay same (no methods changed)");
1611 assert_ne!(schema1.children_hash, schema2.children_hash, "children_hash should change when child hash changes");
1612 assert_ne!(schema1.hash, schema2.hash, "composite hash should change");
1613 }
1614
1615 #[test]
1616 fn test_leaf_has_no_children_hash() {
1617 let schema = PluginSchema::leaf(
1618 "leaf",
1619 "1.0",
1620 "desc",
1621 vec![MethodSchema::new("method", "desc", "hash")],
1622 );
1623
1624 assert!(schema.children_hash.is_none(), "leaf plugin should have None for children_hash");
1625 assert_ne!(schema.self_hash, schema.hash, "leaf plugin's composite hash is hash(self_hash), not equal to self_hash");
1626 }
1627
1628 // =========================================================================
1629 // IR-2 tests: MethodRole, DeprecationInfo, is_hub derived query
1630 // =========================================================================
1631
1632 /// AC #5: Deserializing a JSON `MethodSchema` with no `role` or
1633 /// `deprecation` fields yields `MethodRole::Rpc` and `None`.
1634 #[test]
1635 fn ir2_default_role_is_rpc_on_deserialize() {
1636 // Pre-IR MethodSchema shape (no role, no deprecation, no return_shape)
1637 let pre_ir_json = serde_json::json!({
1638 "name": "ping",
1639 "description": "pong",
1640 "hash": "abc"
1641 });
1642
1643 let schema: MethodSchema = serde_json::from_value(pre_ir_json).unwrap();
1644 assert_eq!(schema.role, MethodRole::Rpc);
1645 assert!(schema.deprecation.is_none());
1646 assert!(schema.return_shape.is_none());
1647 }
1648
1649 /// AC #5: And at the PluginSchema level — a full pre-IR schema with
1650 /// multiple methods (none carrying `role`) deserializes cleanly with every
1651 /// method defaulted to `Rpc` and no deprecation.
1652 #[test]
1653 fn ir2_plugin_schema_pre_ir_json_deserializes() {
1654 let pre_ir_json = serde_json::json!({
1655 "namespace": "test",
1656 "version": "1.0",
1657 "description": "legacy schema",
1658 "self_hash": "s1",
1659 "hash": "h1",
1660 "methods": [
1661 { "name": "a", "description": "alpha", "hash": "ah" },
1662 { "name": "b", "description": "beta", "hash": "bh" }
1663 ]
1664 });
1665
1666 let schema: PluginSchema = serde_json::from_value(pre_ir_json).unwrap();
1667 assert_eq!(schema.methods.len(), 2);
1668 for m in &schema.methods {
1669 assert_eq!(m.role, MethodRole::Rpc);
1670 assert!(m.deprecation.is_none());
1671 }
1672 }
1673
1674 /// AC #6: Serde round-trip covering all `MethodRole` variants —
1675 /// `Rpc`, `StaticChild`, and `DynamicChild { list_method, search_method }`.
1676 #[test]
1677 fn ir2_method_role_roundtrip_all_variants() {
1678 let original = PluginSchema::leaf(
1679 "rt",
1680 "1.0",
1681 "round-trip coverage",
1682 vec![
1683 MethodSchema::new("plain", "rpc", "h1"),
1684 MethodSchema::new("child_a", "static", "h2")
1685 .with_role(MethodRole::StaticChild),
1686 MethodSchema::new("child_b", "dynamic", "h3").with_role(
1687 MethodRole::DynamicChild {
1688 list_method: Some("list_x".into()),
1689 search_method: Some("search_x".into()),
1690 },
1691 ),
1692 ],
1693 );
1694
1695 let json = serde_json::to_string(&original).unwrap();
1696 let decoded: PluginSchema = serde_json::from_str(&json).unwrap();
1697
1698 assert_eq!(decoded.methods[0].role, MethodRole::Rpc);
1699 assert_eq!(decoded.methods[1].role, MethodRole::StaticChild);
1700 assert_eq!(
1701 decoded.methods[2].role,
1702 MethodRole::DynamicChild {
1703 list_method: Some("list_x".into()),
1704 search_method: Some("search_x".into()),
1705 }
1706 );
1707
1708 // Also survives when the DynamicChild has no list/search hints.
1709 let bare_dyn = MethodSchema::new("child_c", "dynamic-bare", "h4").with_role(
1710 MethodRole::DynamicChild {
1711 list_method: None,
1712 search_method: None,
1713 },
1714 );
1715 let j2 = serde_json::to_string(&bare_dyn).unwrap();
1716 let d2: MethodSchema = serde_json::from_str(&j2).unwrap();
1717 assert_eq!(
1718 d2.role,
1719 MethodRole::DynamicChild {
1720 list_method: None,
1721 search_method: None,
1722 }
1723 );
1724 }
1725
1726 /// AC #7: Serde round-trip for `DeprecationInfo` on a `MethodSchema`.
1727 #[test]
1728 fn ir2_deprecation_info_roundtrip() {
1729 let info = DeprecationInfo {
1730 since: "0.5".into(),
1731 removed_in: "0.6".into(),
1732 message: "use MethodRole".into(),
1733 };
1734 let method = MethodSchema::new("old", "legacy method", "hx")
1735 .with_deprecation(info.clone());
1736
1737 let json = serde_json::to_string(&method).unwrap();
1738 let decoded: MethodSchema = serde_json::from_str(&json).unwrap();
1739
1740 assert_eq!(decoded.deprecation, Some(info));
1741 }
1742
1743 /// AC #4: `PluginSchema::is_hub_by_role()` — the derived query reads only
1744 /// `methods`, not the legacy `children` field.
1745 ///
1746 /// Covers every row of the acceptance-criteria table.
1747 #[test]
1748 fn ir2_is_hub_by_role_derived_query() {
1749 // Row 1: all Rpc → false
1750 let all_rpc = PluginSchema::leaf(
1751 "p",
1752 "1.0",
1753 "all rpc",
1754 vec![
1755 MethodSchema::new("a", "d", "h1"),
1756 MethodSchema::new("b", "d", "h2"),
1757 ],
1758 );
1759 assert!(!all_rpc.is_hub_by_role());
1760 // And the back-compat `is_hub()` also returns false (no children).
1761 assert!(!all_rpc.is_hub());
1762
1763 // Row 2: at least one StaticChild → true
1764 let static_child = PluginSchema::leaf(
1765 "p",
1766 "1.0",
1767 "has static child",
1768 vec![
1769 MethodSchema::new("a", "d", "h1"),
1770 MethodSchema::new("kid", "d", "h2").with_role(MethodRole::StaticChild),
1771 ],
1772 );
1773 assert!(static_child.is_hub_by_role());
1774 assert!(static_child.is_hub());
1775
1776 // Row 3: at least one DynamicChild → true
1777 let dyn_child = PluginSchema::leaf(
1778 "p",
1779 "1.0",
1780 "has dynamic child",
1781 vec![MethodSchema::new("find", "d", "h1").with_role(
1782 MethodRole::DynamicChild {
1783 list_method: None,
1784 search_method: None,
1785 },
1786 )],
1787 );
1788 assert!(dyn_child.is_hub_by_role());
1789 assert!(dyn_child.is_hub());
1790
1791 // Row 4: Mix of Rpc + StaticChild → true
1792 let mixed = PluginSchema::leaf(
1793 "p",
1794 "1.0",
1795 "mixed",
1796 vec![
1797 MethodSchema::new("a", "d", "h1"),
1798 MethodSchema::new("b", "d", "h2"),
1799 MethodSchema::new("k", "d", "h3").with_role(MethodRole::StaticChild),
1800 ],
1801 );
1802 assert!(mixed.is_hub_by_role());
1803 assert!(mixed.is_hub());
1804
1805 // Row 5: empty methods → false
1806 let empty = PluginSchema::leaf("p", "1.0", "empty", vec![]);
1807 assert!(!empty.is_hub_by_role());
1808 assert!(!empty.is_hub());
1809 }
1810
1811 /// The derived query is independent of the legacy `children` side channel
1812 /// — a `PluginSchema::hub(...)` with only `Rpc` methods reports
1813 /// `is_hub_by_role() == false` (children don't count) while `is_hub()` is
1814 /// still `true` (transition-window fallback).
1815 #[test]
1816 fn ir2_is_hub_by_role_ignores_children_field() {
1817 let hub_with_rpc_only = PluginSchema::hub(
1818 "h",
1819 "1.0",
1820 "transition",
1821 vec![MethodSchema::new("a", "d", "ah")],
1822 vec![ChildSummary {
1823 namespace: "kid".into(),
1824 description: "child".into(),
1825 hash: "kh".into(),
1826 }],
1827 );
1828
1829 // The derived query reads only methods — no child role → false.
1830 assert!(!hub_with_rpc_only.is_hub_by_role());
1831 // Back-compat `is_hub()` still reports true via the children fallback.
1832 assert!(hub_with_rpc_only.is_hub());
1833 }
1834
1835 /// `ReturnShape` round-trips cleanly via serde.
1836 #[test]
1837 fn ir2_return_shape_roundtrip() {
1838 for shape in [
1839 ReturnShape::Bare,
1840 ReturnShape::Option,
1841 ReturnShape::Result,
1842 ReturnShape::Vec,
1843 ReturnShape::Stream,
1844 ReturnShape::ResultOption,
1845 ] {
1846 let m = MethodSchema::new("m", "d", "h").with_return_shape(shape.clone());
1847 let j = serde_json::to_string(&m).unwrap();
1848 let d: MethodSchema = serde_json::from_str(&j).unwrap();
1849 assert_eq!(d.return_shape, Some(shape));
1850 }
1851 }
1852
1853 // =========================================================================
1854 // IR-4 tests: derive_legacy_fields, relaxed validate_no_collisions,
1855 // deprecation markers.
1856 // =========================================================================
1857
1858 /// AC #4 (row 1): empty method list → no children, not a hub.
1859 #[test]
1860 fn ir4_derive_empty_methods() {
1861 let (children, is_hub) = PluginSchema::derive_legacy_fields(&[]);
1862 assert!(children.is_empty());
1863 assert!(!is_hub);
1864 }
1865
1866 /// AC #4 (row 2): a single `Rpc` method → no children, not a hub.
1867 #[test]
1868 fn ir4_derive_single_rpc_method() {
1869 let methods = vec![MethodSchema::new("ping", "rpc method", "h1")];
1870 let (children, is_hub) = PluginSchema::derive_legacy_fields(&methods);
1871 assert!(children.is_empty());
1872 assert!(!is_hub);
1873 }
1874
1875 /// AC #4 (row 3): one `StaticChild` method named "body" → one child named
1876 /// "body", `is_hub == true`.
1877 #[test]
1878 fn ir4_derive_single_static_child() {
1879 let methods = vec![
1880 MethodSchema::new("body", "static child", "h1")
1881 .with_role(MethodRole::StaticChild),
1882 ];
1883 let (children, is_hub) = PluginSchema::derive_legacy_fields(&methods);
1884 assert_eq!(children.len(), 1);
1885 assert_eq!(children[0].namespace, "body");
1886 assert_eq!(children[0].description, "static child");
1887 assert_eq!(children[0].hash, "");
1888 assert!(is_hub);
1889 }
1890
1891 /// AC #4 (row 4): one `DynamicChild` method named "planet" → one child
1892 /// named "planet", `is_hub == true`.
1893 #[test]
1894 fn ir4_derive_single_dynamic_child() {
1895 let methods = vec![
1896 MethodSchema::new("planet", "dynamic child", "h1").with_role(
1897 MethodRole::DynamicChild {
1898 list_method: Some("list_planets".into()),
1899 search_method: None,
1900 },
1901 ),
1902 ];
1903 let (children, is_hub) = PluginSchema::derive_legacy_fields(&methods);
1904 assert_eq!(children.len(), 1);
1905 assert_eq!(children[0].namespace, "planet");
1906 assert!(is_hub);
1907 }
1908
1909 /// AC #4 (row 5): mix of Rpc + StaticChild → one child, `is_hub == true`.
1910 #[test]
1911 fn ir4_derive_mixed_roles_preserves_order() {
1912 let methods = vec![
1913 MethodSchema::new("ping", "rpc", "h1"),
1914 MethodSchema::new("kid_a", "static a", "h2")
1915 .with_role(MethodRole::StaticChild),
1916 MethodSchema::new("describe", "rpc too", "h3"),
1917 MethodSchema::new("kid_b", "static b", "h4")
1918 .with_role(MethodRole::StaticChild),
1919 ];
1920 let (children, is_hub) = PluginSchema::derive_legacy_fields(&methods);
1921 // Source-order preservation: kid_a appears before kid_b.
1922 assert_eq!(children.len(), 2);
1923 assert_eq!(children[0].namespace, "kid_a");
1924 assert_eq!(children[1].namespace, "kid_b");
1925 assert!(is_hub);
1926 }
1927
1928 /// IR-4: `derive_legacy_fields`'s `is_hub` result matches
1929 /// [`PluginSchema::is_hub_by_role`] on every method list covered by the
1930 /// acceptance-criteria table.
1931 #[test]
1932 fn ir4_derive_is_hub_matches_is_hub_by_role() {
1933 // Empty methods.
1934 let empty_schema = PluginSchema::leaf("t", "1.0", "d", vec![]);
1935 let (_, is_hub) = PluginSchema::derive_legacy_fields(&empty_schema.methods);
1936 assert_eq!(is_hub, empty_schema.is_hub_by_role());
1937
1938 // All-Rpc methods.
1939 let rpc_schema = PluginSchema::leaf(
1940 "t",
1941 "1.0",
1942 "d",
1943 vec![
1944 MethodSchema::new("a", "d", "h1"),
1945 MethodSchema::new("b", "d", "h2"),
1946 ],
1947 );
1948 let (_, is_hub) = PluginSchema::derive_legacy_fields(&rpc_schema.methods);
1949 assert_eq!(is_hub, rpc_schema.is_hub_by_role());
1950
1951 // StaticChild present.
1952 let static_schema = PluginSchema::leaf(
1953 "t",
1954 "1.0",
1955 "d",
1956 vec![
1957 MethodSchema::new("a", "d", "h1"),
1958 MethodSchema::new("kid", "d", "h2").with_role(MethodRole::StaticChild),
1959 ],
1960 );
1961 let (_, is_hub) = PluginSchema::derive_legacy_fields(&static_schema.methods);
1962 assert_eq!(is_hub, static_schema.is_hub_by_role());
1963 assert!(is_hub);
1964
1965 // DynamicChild present.
1966 let dyn_schema = PluginSchema::leaf(
1967 "t",
1968 "1.0",
1969 "d",
1970 vec![MethodSchema::new("find", "d", "h1").with_role(
1971 MethodRole::DynamicChild {
1972 list_method: None,
1973 search_method: None,
1974 },
1975 )],
1976 );
1977 let (_, is_hub) = PluginSchema::derive_legacy_fields(&dyn_schema.methods);
1978 assert_eq!(is_hub, dyn_schema.is_hub_by_role());
1979 assert!(is_hub);
1980 }
1981
1982 /// IR-4 rule 2: `validate_no_collisions` no longer panics when a
1983 /// `StaticChild`-role method shares its name with a `ChildSummary` —
1984 /// that's expected by construction (two wire representations of the
1985 /// same child).
1986 #[test]
1987 fn ir4_no_collision_static_child_method_vs_summary() {
1988 // Same name on both surfaces — used to panic, now accepted.
1989 let schema = PluginSchema::hub(
1990 "hub",
1991 "1.0",
1992 "has static child",
1993 vec![
1994 MethodSchema::new("ping", "rpc", "h1"),
1995 MethodSchema::new("kid", "static child", "h2")
1996 .with_role(MethodRole::StaticChild),
1997 ],
1998 vec![ChildSummary {
1999 namespace: "kid".into(),
2000 description: "static child".into(),
2001 hash: "kh".into(),
2002 }],
2003 );
2004 // Child stayed on the wire.
2005 #[allow(deprecated)]
2006 let kids = schema.children.as_ref().expect("hub has children");
2007 assert_eq!(kids.len(), 1);
2008 assert_eq!(kids[0].namespace, "kid");
2009 // Method kept its role tag.
2010 assert!(matches!(
2011 schema.methods.iter().find(|m| m.name == "kid").unwrap().role,
2012 MethodRole::StaticChild
2013 ));
2014 }
2015
2016 /// IR-4 rule 2: `validate_no_collisions` also tolerates DynamicChild-role
2017 /// method names that appear in the child summary list.
2018 #[test]
2019 fn ir4_no_collision_dynamic_child_method_vs_summary() {
2020 let schema = PluginSchema::hub(
2021 "hub",
2022 "1.0",
2023 "has dynamic child",
2024 vec![MethodSchema::new("body", "gate", "h1").with_role(
2025 MethodRole::DynamicChild {
2026 list_method: Some("body_names".into()),
2027 search_method: None,
2028 },
2029 )],
2030 vec![ChildSummary {
2031 namespace: "body".into(),
2032 description: "gate".into(),
2033 hash: "bh".into(),
2034 }],
2035 );
2036 #[allow(deprecated)]
2037 let kids = schema.children.as_ref().unwrap();
2038 assert_eq!(kids.len(), 1);
2039 }
2040
2041 /// IR-4 rule 2: `validate_no_collisions` still panics when an `Rpc`-role
2042 /// method's name collides with a child summary — that's the case the
2043 /// validation was designed to catch.
2044 #[test]
2045 #[should_panic(expected = "method/child collision")]
2046 fn ir4_collision_rpc_method_vs_summary_still_panics() {
2047 let _ = PluginSchema::hub(
2048 "hub",
2049 "1.0",
2050 "bad hub",
2051 vec![MethodSchema::new("oops", "rpc", "h1")],
2052 vec![ChildSummary {
2053 namespace: "oops".into(),
2054 description: "shadowed".into(),
2055 hash: "oh".into(),
2056 }],
2057 );
2058 }
2059
2060 /// IR-4 AC #3 (spec): reading `PluginSchema.children` outside a
2061 /// `#[allow(deprecated)]` block emits a compiler warning. This fixture
2062 /// uses `#[allow(deprecated)]` to confirm the attribute is required —
2063 /// if it weren't, the `#[deprecated]` annotation is either missing or
2064 /// wrong.
2065 #[test]
2066 fn ir4_deprecated_field_access_requires_allow_attribute() {
2067 let schema = PluginSchema::leaf(
2068 "t",
2069 "1.0",
2070 "d",
2071 vec![MethodSchema::new("a", "b", "h")],
2072 );
2073 // Reading the deprecated field — under `#[allow(deprecated)]` from
2074 // the module-level attribute on the tests module. Removing that
2075 // allow would produce a compiler warning pointing at this line.
2076 let _children = schema.children.clone();
2077 // Calling the deprecated method — same rationale.
2078 let _is_hub = schema.is_hub();
2079 }
2080
2081 /// AUTHZ-CRED-CORE-2 AC #8: `PluginSchema::leaf` runs the
2082 /// schema-build warning hook for the framework-reserved
2083 /// `_credentials` top-level field name. The hook itself emits a
2084 /// `tracing::warn!` line, which is not directly assertable here
2085 /// without a tracing subscriber; we exercise the predicate path
2086 /// through schema construction (no panic, schema still constructed)
2087 /// and assert that the underlying check function is wired in.
2088 #[test]
2089 fn cred_core_2_ac8_leaf_constructor_does_not_panic_on_collision() {
2090 // Build a returns schema with a top-level `_credentials` field —
2091 // the framework-reserved name.
2092 let returns_collision_schema: schemars::Schema = serde_json::from_value(
2093 serde_json::json!({
2094 "type": "object",
2095 "properties": {
2096 "user_id": { "type": "string" },
2097 "_credentials": { "type": "object" }
2098 }
2099 }),
2100 )
2101 .unwrap();
2102
2103 // The leaf constructor consults the warning hook but does NOT
2104 // fail — the field is shadowed at dispatch time, and the
2105 // warning is the user-visible diagnostic. Constructing the
2106 // schema must still succeed (additive behavior).
2107 let schema = PluginSchema::leaf(
2108 "auth",
2109 "1.0",
2110 "test",
2111 vec![MethodSchema::new("login", "logs in", "h1").with_returns(returns_collision_schema)],
2112 );
2113 // Plugin still built.
2114 assert_eq!(schema.namespace, "auth");
2115 assert_eq!(schema.methods.len(), 1);
2116
2117 // And the inverse: a schema without the collision does not
2118 // panic either.
2119 let no_collision_schema: schemars::Schema = serde_json::from_value(
2120 serde_json::json!({ "type": "object", "properties": { "user_id": { "type": "string" } } }),
2121 )
2122 .unwrap();
2123 let schema_clean = PluginSchema::leaf(
2124 "auth",
2125 "1.0",
2126 "test",
2127 vec![MethodSchema::new("ping", "pings", "h2").with_returns(no_collision_schema)],
2128 );
2129 assert_eq!(schema_clean.methods.len(), 1);
2130 }
2131
2132 /// IR-4 AC #8: `PluginSchema.is_hub()` (deprecated) and
2133 /// `PluginSchema::is_hub_by_role()` agree on every shape currently
2134 /// emitted by substrate activations (methods with role tags, children
2135 /// field populated via hub constructor).
2136 #[test]
2137 fn ir4_is_hub_and_is_hub_by_role_agree_on_role_tagged_methods() {
2138 // Pure-leaf, all Rpc: both false.
2139 let leaf = PluginSchema::leaf(
2140 "t",
2141 "1.0",
2142 "d",
2143 vec![MethodSchema::new("a", "d", "h1")],
2144 );
2145 assert_eq!(leaf.is_hub(), leaf.is_hub_by_role());
2146 assert!(!leaf.is_hub());
2147
2148 // Hub with role-tagged methods (today's post-IR-3 shape): both true.
2149 let hub_with_roles = PluginSchema::hub(
2150 "h",
2151 "1.0",
2152 "d",
2153 vec![MethodSchema::new("kid", "d", "h1").with_role(MethodRole::StaticChild)],
2154 vec![ChildSummary {
2155 namespace: "kid".into(),
2156 description: "d".into(),
2157 hash: "".into(),
2158 }],
2159 );
2160 assert_eq!(hub_with_roles.is_hub(), hub_with_roles.is_hub_by_role());
2161 assert!(hub_with_roles.is_hub());
2162 }
2163
2164 // =========================================================================
2165 // AUTHZ-CRED-CORE-3 tests: CredentialFieldDecl, RequiredCredential,
2166 // MethodSchema.credentials / .requires_credential projections.
2167 // =========================================================================
2168
2169 use plexus_auth_core::{
2170 AttachmentSite, CredentialFieldMarker, CredentialIssuer, CredentialKind,
2171 CredentialMetadata, CredentialScheme, HeaderName, MethodPath, Origin, Scope,
2172 };
2173
2174 fn sample_issuer() -> CredentialIssuer {
2175 CredentialIssuer::new(
2176 Origin::new("ws://localhost:4444"),
2177 MethodPath::try_new("auth.login").unwrap(),
2178 )
2179 }
2180
2181 fn sample_marker_single() -> CredentialFieldMarker {
2182 CredentialFieldMarker::new(
2183 None,
2184 "session",
2185 CredentialKind::Bearer,
2186 AttachmentSite::Header {
2187 name: HeaderName::try_new("authorization").unwrap(),
2188 },
2189 Some(CredentialScheme::new("Bearer ")),
2190 vec![Scope::new("cone.send_message")],
2191 Some(MethodPath::try_new("auth.refresh").unwrap()),
2192 Some(MethodPath::try_new("auth.logout").unwrap()),
2193 )
2194 }
2195
2196 /// AC #1: A method whose return type contains one `Credential<T>` field
2197 /// produces a `MethodSchema` whose `credentials` field has one
2198 /// `CredentialFieldDecl` entry with the correct field path and metadata.
2199 #[test]
2200 fn cred_core_3_ac1_single_credential_projection() {
2201 let marker = sample_marker_single();
2202 let decl = CredentialFieldDecl::from_marker(&marker, &[], sample_issuer());
2203
2204 // Field path is the marker's single field name.
2205 assert_eq!(decl.field_path, vec!["session".to_string()]);
2206 // No variant tag for a struct return type.
2207 assert!(decl.variant_tag.is_none());
2208 // Metadata composes correctly from the marker.
2209 assert_eq!(decl.metadata.kind, CredentialKind::Bearer);
2210 assert_eq!(
2211 decl.metadata.attach_as,
2212 AttachmentSite::Header {
2213 name: HeaderName::try_new("authorization").unwrap(),
2214 }
2215 );
2216 assert_eq!(decl.metadata.scheme, Some(CredentialScheme::new("Bearer ")));
2217 assert_eq!(decl.metadata.scopes, vec![Scope::new("cone.send_message")]);
2218 assert_eq!(
2219 decl.metadata.refresh_via,
2220 Some(MethodPath::try_new("auth.refresh").unwrap())
2221 );
2222 assert_eq!(
2223 decl.metadata.revoke_via,
2224 Some(MethodPath::try_new("auth.logout").unwrap())
2225 );
2226 assert_eq!(decl.metadata.issuer, sample_issuer());
2227
2228 // Project into a MethodSchema and confirm the field is populated.
2229 let method = MethodSchema::new("login", "logs in", "h_login").with_credentials(vec![decl]);
2230 assert_eq!(method.credentials.len(), 1);
2231 assert_eq!(method.credentials[0].field_path, vec!["session".to_string()]);
2232 }
2233
2234 /// AC #2: A method whose return type contains multiple `Credential<T>`
2235 /// fields produces a `MethodSchema` whose `credentials` field has one
2236 /// entry per credential, in stable order.
2237 #[test]
2238 fn cred_core_3_ac2_multiple_credentials_stable_order() {
2239 let m1 = CredentialFieldMarker::new(
2240 None,
2241 "access",
2242 CredentialKind::OauthAccess,
2243 AttachmentSite::Header {
2244 name: HeaderName::try_new("authorization").unwrap(),
2245 },
2246 Some(CredentialScheme::new("Bearer ")),
2247 vec![Scope::new("cone.send")],
2248 Some(MethodPath::try_new("auth.refresh").unwrap()),
2249 None,
2250 );
2251 let m2 = CredentialFieldMarker::new(
2252 None,
2253 "refresh",
2254 CredentialKind::OauthRefresh,
2255 AttachmentSite::Header {
2256 name: HeaderName::try_new("authorization").unwrap(),
2257 },
2258 None,
2259 vec![Scope::new("auth.refresh")],
2260 None,
2261 None,
2262 );
2263
2264 let decls = vec![
2265 CredentialFieldDecl::from_marker(&m1, &[], sample_issuer()),
2266 CredentialFieldDecl::from_marker(&m2, &[], sample_issuer()),
2267 ];
2268 let method = MethodSchema::new("login", "logs in", "h_oauth").with_credentials(decls);
2269
2270 // Two entries, in declaration order.
2271 assert_eq!(method.credentials.len(), 2);
2272 assert_eq!(method.credentials[0].field_path, vec!["access".to_string()]);
2273 assert_eq!(method.credentials[0].metadata.kind, CredentialKind::OauthAccess);
2274 assert_eq!(method.credentials[1].field_path, vec!["refresh".to_string()]);
2275 assert_eq!(method.credentials[1].metadata.kind, CredentialKind::OauthRefresh);
2276 }
2277
2278 /// AC #3: A method whose return type is an enum with credentials only on
2279 /// one variant produces a `MethodSchema` with the correct `variant_tag`
2280 /// set on each entry.
2281 #[test]
2282 fn cred_core_3_ac3_enum_variant_tag_set() {
2283 let marker = CredentialFieldMarker::new(
2284 Some("Issued"),
2285 "session",
2286 CredentialKind::Bearer,
2287 AttachmentSite::Header {
2288 name: HeaderName::try_new("authorization").unwrap(),
2289 },
2290 Some(CredentialScheme::new("Bearer ")),
2291 vec![Scope::new("cone.send_message")],
2292 None,
2293 None,
2294 );
2295 let decl = CredentialFieldDecl::from_marker(&marker, &[], sample_issuer());
2296 assert_eq!(decl.variant_tag, Some("Issued".to_string()));
2297 assert_eq!(decl.field_path, vec!["session".to_string()]);
2298
2299 let method = MethodSchema::new("login", "logs in", "h_login").with_credentials(vec![decl]);
2300 assert_eq!(method.credentials[0].variant_tag, Some("Issued".to_string()));
2301 }
2302
2303 /// `CredentialFieldDecl::from_marker` honors a non-empty path prefix
2304 /// (nested-field walk).
2305 #[test]
2306 fn cred_core_3_from_marker_with_nested_path_prefix() {
2307 let marker = sample_marker_single();
2308 let decl = CredentialFieldDecl::from_marker(&marker, &["auth"], sample_issuer());
2309 assert_eq!(decl.field_path, vec!["auth".to_string(), "session".to_string()]);
2310 }
2311
2312 /// AC #4: A method tagged `#[plexus::method(public)]` produces a
2313 /// `MethodSchema` whose `requires_credential` is `None`.
2314 ///
2315 /// "Public" maps to "no implicit derivation occurs"; the schema-build
2316 /// surface simply doesn't call `with_requires_credential`, leaving the
2317 /// field at its `None` default.
2318 #[test]
2319 fn cred_core_3_ac4_public_method_requires_credential_none() {
2320 let method = MethodSchema::new("auth.login", "logs in", "h_login");
2321 assert!(method.requires_credential.is_none());
2322
2323 // After round-trip the field also serializes/deserializes as None
2324 // (omitted on the wire).
2325 let json = serde_json::to_value(&method).unwrap();
2326 assert!(
2327 !json
2328 .as_object()
2329 .unwrap()
2330 .contains_key("requires_credential"),
2331 "requires_credential must be omitted from wire JSON when None, got {json}"
2332 );
2333 let decoded: MethodSchema = serde_json::from_value(json).unwrap();
2334 assert!(decoded.requires_credential.is_none());
2335 }
2336
2337 /// AC #5: A method tagged with a scope produces a `MethodSchema` whose
2338 /// `requires_credential.scopes` contains that scope and whose
2339 /// `requires_credential.kind` is `None`.
2340 #[test]
2341 fn cred_core_3_ac5_scoped_method_implicit_requires_credential() {
2342 let req = RequiredCredential::from_method_scope(Scope::new("cone.send_message"));
2343 assert!(req.kind.is_none());
2344 assert_eq!(req.scopes, vec![Scope::new("cone.send_message")]);
2345 assert!(req.site_hint.is_none());
2346
2347 let method = MethodSchema::new("send", "sends a message", "h_send")
2348 .with_requires_credential(req.clone());
2349 assert_eq!(method.requires_credential.as_ref().unwrap(), &req);
2350 }
2351
2352 /// AC #6: A method whose path appears as the `refresh_via` or
2353 /// `revoke_via` of any credential in the schema produces a `MethodSchema`
2354 /// whose `requires_credential.kind` matches the issuing credential's
2355 /// kind.
2356 #[test]
2357 fn cred_core_3_ac6_refresh_target_narrows_kind() {
2358 // The OAuth refresh flow: the access credential carries
2359 // `refresh_via = auth.refresh`. The implicit-derivation rule
2360 // narrows the requires_credential of `auth.refresh` to
2361 // `OauthRefresh`.
2362 let req = RequiredCredential::from_refresh_revoke_target(
2363 CredentialKind::OauthRefresh,
2364 vec![Scope::new("auth.refresh")],
2365 );
2366 assert_eq!(req.kind, Some(CredentialKind::OauthRefresh));
2367 assert_eq!(req.scopes, vec![Scope::new("auth.refresh")]);
2368
2369 let refresh_method =
2370 MethodSchema::new("refresh", "refreshes a token", "h_refresh")
2371 .with_requires_credential(req.clone());
2372 assert_eq!(
2373 refresh_method.requires_credential.as_ref().unwrap().kind,
2374 Some(CredentialKind::OauthRefresh)
2375 );
2376 }
2377
2378 /// AC #6 variant: site_hint can be threaded through if a build-path
2379 /// derivation step has a preferred attach site (e.g., the OAuth refresh
2380 /// path that knows the refresh token is attached via header).
2381 #[test]
2382 fn cred_core_3_required_credential_site_hint_preserved() {
2383 let mut req = RequiredCredential::from_refresh_revoke_target(
2384 CredentialKind::OauthRefresh,
2385 vec![Scope::new("auth.refresh")],
2386 );
2387 let hint = AttachmentSite::Header {
2388 name: HeaderName::try_new("authorization").unwrap(),
2389 };
2390 req.site_hint = Some(hint.clone());
2391 let method = MethodSchema::new("refresh", "refreshes", "h_refresh")
2392 .with_requires_credential(req);
2393
2394 let json = serde_json::to_string(&method).unwrap();
2395 let decoded: MethodSchema = serde_json::from_str(&json).unwrap();
2396 assert_eq!(
2397 decoded.requires_credential.as_ref().unwrap().site_hint,
2398 Some(hint)
2399 );
2400 }
2401
2402 /// AC #7: A pre-existing schema consumer (synapse IR builder,
2403 /// hub-codegen) decodes the new schema fields with their additive
2404 /// defaults and continues to function.
2405 ///
2406 /// Tested by deserializing a pre-CRED-CORE-3 JSON shape (no
2407 /// `credentials`, no `requires_credential` fields) and asserting the
2408 /// defaults are applied.
2409 #[test]
2410 fn cred_core_3_ac7_pre_ir_json_deserializes_with_empty_defaults() {
2411 let pre_ir_json = serde_json::json!({
2412 "name": "ping",
2413 "description": "pong",
2414 "hash": "abc"
2415 });
2416 let schema: MethodSchema = serde_json::from_value(pre_ir_json).unwrap();
2417 assert!(schema.credentials.is_empty());
2418 assert!(schema.requires_credential.is_none());
2419 }
2420
2421 /// AC #7 variant: a pre-IR PluginSchema with multiple methods
2422 /// deserializes cleanly with every method's credential projections
2423 /// defaulted.
2424 #[test]
2425 fn cred_core_3_ac7_pre_ir_plugin_schema_deserializes() {
2426 let pre_ir_json = serde_json::json!({
2427 "namespace": "legacy",
2428 "version": "1.0",
2429 "description": "no credentials",
2430 "self_hash": "s1",
2431 "hash": "h1",
2432 "methods": [
2433 { "name": "a", "description": "alpha", "hash": "ah" },
2434 { "name": "b", "description": "beta", "hash": "bh" }
2435 ]
2436 });
2437 let plugin: PluginSchema = serde_json::from_value(pre_ir_json).unwrap();
2438 for m in &plugin.methods {
2439 assert!(m.credentials.is_empty());
2440 assert!(m.requires_credential.is_none());
2441 }
2442 }
2443
2444 /// AC #8: The `_info` capability advertisement carries the new fields
2445 /// when populated.
2446 ///
2447 /// `_info` reads `MethodSchema`'s serde representation directly. We
2448 /// verify the wire JSON contains the new fields when populated.
2449 #[test]
2450 fn cred_core_3_ac8_info_advertisement_carries_populated_fields() {
2451 let marker = sample_marker_single();
2452 let decl = CredentialFieldDecl::from_marker(&marker, &[], sample_issuer());
2453 let req = RequiredCredential::from_method_scope(Scope::new("cone.send_message"));
2454 let method = MethodSchema::new("login", "logs in", "h_login")
2455 .with_credentials(vec![decl])
2456 .with_requires_credential(req);
2457
2458 let json = serde_json::to_value(&method).unwrap();
2459 let obj = json.as_object().unwrap();
2460 assert!(
2461 obj.contains_key("credentials"),
2462 "populated credentials must appear in wire JSON"
2463 );
2464 assert!(
2465 obj.contains_key("requires_credential"),
2466 "populated requires_credential must appear in wire JSON"
2467 );
2468 // And the entry shape is decodeable.
2469 let decoded: MethodSchema = serde_json::from_value(json).unwrap();
2470 assert_eq!(decoded.credentials.len(), 1);
2471 assert_eq!(decoded.credentials[0].field_path, vec!["session".to_string()]);
2472 assert_eq!(
2473 decoded.requires_credential.as_ref().unwrap().scopes,
2474 vec![Scope::new("cone.send_message")]
2475 );
2476 }
2477
2478 /// AC #11 (no regression): a method that does not return credentials and
2479 /// has no scope-derived requirement has identical wire JSON to a pre-IR
2480 /// schema for the same method (the new fields are omitted on the wire).
2481 #[test]
2482 fn cred_core_3_ac11_methods_without_credentials_have_unchanged_wire_shape() {
2483 let method = MethodSchema::new("ping", "pong", "h_ping");
2484 let json = serde_json::to_value(&method).unwrap();
2485 let obj = json.as_object().unwrap();
2486 // No credentials key, no requires_credential key.
2487 assert!(!obj.contains_key("credentials"));
2488 assert!(!obj.contains_key("requires_credential"));
2489
2490 // And the round-trip preserves the empty defaults.
2491 let decoded: MethodSchema = serde_json::from_value(json).unwrap();
2492 assert!(decoded.credentials.is_empty());
2493 assert!(decoded.requires_credential.is_none());
2494 }
2495
2496 /// Round-trip coverage: a full `MethodSchema` with both projections
2497 /// populated round-trips through serde without losing fields.
2498 #[test]
2499 fn cred_core_3_full_method_roundtrip_preserves_credentials_and_requires() {
2500 let marker = sample_marker_single();
2501 let decl = CredentialFieldDecl::from_marker(&marker, &[], sample_issuer());
2502 let req = RequiredCredential::from_method_scope(Scope::new("cone.send_message"));
2503 let method = MethodSchema::new("login", "logs in", "h_login")
2504 .with_credentials(vec![decl.clone()])
2505 .with_requires_credential(req.clone());
2506
2507 let json = serde_json::to_string(&method).unwrap();
2508 let decoded: MethodSchema = serde_json::from_str(&json).unwrap();
2509
2510 assert_eq!(decoded.credentials.len(), 1);
2511 assert_eq!(decoded.credentials[0], decl);
2512 assert_eq!(decoded.requires_credential.as_ref().unwrap(), &req);
2513 }
2514
2515 /// `CredentialFieldDecl` round-trips via serde — pinned independently
2516 /// of MethodSchema so consumers reading the decl in isolation (e.g.
2517 /// `AUTHZ-CRED-IR-1` Haskell decoder testing parity) have a
2518 /// trustworthy shape contract.
2519 #[test]
2520 fn cred_core_3_credential_field_decl_roundtrip() {
2521 let marker = sample_marker_single();
2522 let original = CredentialFieldDecl::from_marker(&marker, &["envelope"], sample_issuer());
2523 let json = serde_json::to_string(&original).unwrap();
2524 let parsed: CredentialFieldDecl = serde_json::from_str(&json).unwrap();
2525 assert_eq!(parsed, original);
2526 }
2527
2528 /// `RequiredCredential` round-trips via serde — pinned independently
2529 /// for the same cross-stack reason as `CredentialFieldDecl`.
2530 #[test]
2531 fn cred_core_3_required_credential_roundtrip() {
2532 let req = RequiredCredential {
2533 kind: Some(CredentialKind::OauthRefresh),
2534 scopes: vec![Scope::new("auth.refresh")],
2535 site_hint: Some(AttachmentSite::Header {
2536 name: HeaderName::try_new("authorization").unwrap(),
2537 }),
2538 };
2539 let json = serde_json::to_string(&req).unwrap();
2540 let parsed: RequiredCredential = serde_json::from_str(&json).unwrap();
2541 assert_eq!(parsed, req);
2542 }
2543
2544 /// `RequiredCredential` with `kind: None, scopes: [...], site_hint:
2545 /// None` (the `from_method_scope` shape) serializes to a compact wire
2546 /// form that omits the absent fields.
2547 #[test]
2548 fn cred_core_3_required_credential_compact_wire_shape() {
2549 let req = RequiredCredential::from_method_scope(Scope::new("cone.send"));
2550 let json = serde_json::to_value(&req).unwrap();
2551 let obj = json.as_object().unwrap();
2552 // Only the scopes field is present on the wire.
2553 assert!(!obj.contains_key("kind"));
2554 assert!(obj.contains_key("scopes"));
2555 assert!(!obj.contains_key("site_hint"));
2556 }
2557}