protocol_droid/domain/mcp/resource_metadata.rs
1//! The canonical resource identifier and the document that publishes it.
2//!
3//! RFC 9728 ("OAuth 2.0 Protected Resource Metadata") is what makes an MCP
4//! server discoverable as an OAuth resource server: a server publishes a
5//! metadata document naming its authorization servers, and a client that gets
6//! a `401` reads it to find out where to get a token.
7//!
8//! That document's `resource` field *is* the audience. RFC 8707 resource
9//! indicators bind a token to it, and this crate refuses a token whose audience
10//! is not exactly this string. So [`ResourceUri`] is a value object rather than
11//! a convenience: two spellings of the same server that compare unequal is a
12//! token rejected for a reason nobody can see.
13//!
14//! # Where this lives
15//!
16//! `domain/`, because the canonicalisation rules are the security-relevant
17//! part and an adapter should not get to choose them. Which *revisions* require
18//! any of this is `authorization_model`.
19
20use std::sync::Arc;
21
22use core::fmt;
23use core::str::FromStr;
24
25use serde::{Serialize, Serializer};
26
27/// The well-known path an MCP server publishes its metadata at.
28///
29/// RFC 9728 §3.1: the metadata for a resource at the origin root is
30/// `/.well-known/oauth-protected-resource`, and the metadata for a resource at
31/// some other path is `/.well-known/oauth-protected-resource` with that path
32/// *appended*. So a server mounted at `/mcp` publishes at
33/// `/.well-known/oauth-protected-resource/mcp` — see
34/// [`ResourceUri::metadata_path`].
35pub const WELL_KNOWN: &str = "/.well-known/oauth-protected-resource";
36
37/// How a bearer token reaches the server.
38///
39/// RFC 9728 §2: `bearer_methods_supported` names the mechanisms this resource
40/// accepts. Only the `header` mechanism is defined by the RFC, and it is the
41/// only one this crate reads a token from — the alternative in the spec's
42/// discussion is the `Authorization` request body parameter, which MCP's
43/// transports have no place for.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize)]
45#[serde(rename_all = "snake_case")]
46pub enum BearerMethod {
47 /// RFC 6750 §2.1, the `Authorization: Bearer` header.
48 ///
49 /// The wire spelling is lower case because that is what the RFC's
50 /// `bearer_methods_supported` array contains and what a client's parser
51 /// compares against.
52 #[default]
53 Header,
54}
55
56impl BearerMethod {
57 /// The value as it appears in a `bearer_methods_supported` array.
58 #[must_use]
59 pub const fn as_str(self) -> &'static str {
60 match self {
61 Self::Header => "header",
62 }
63 }
64}
65
66/// The canonical resource identifier for an MCP server: the `resource` field,
67/// and the audience a token must carry.
68///
69/// # Canonicalisation
70///
71/// Two rules, both from RFC 9728 §2 and RFC 8707 §2, and both of which a
72/// hand-rolled string comparison would get wrong:
73///
74/// - **No trailing slash.** `https://example.com/mcp` and
75/// `https://example.com/mcp/` are the same resource to a human and two
76/// different strings to an `aud` comparison, so the slash is dropped once.
77/// A bare origin (`https://example.com`) keeps none.
78/// - **No default port.** `https://example.com:443/mcp` and
79/// `https://example.com/mcp` are the same resource, so `443` and `80` are
80/// dropped for their schemes.
81///
82/// Scheme and host are **not** case-folded beyond parsing the scheme, because
83/// the host part is not something this crate can safely normalise: IDNA
84/// punycode and case-sensitive comparison are a much larger problem than
85/// authorization needs, and a wrong fold is a wrong audience. What is folded is
86/// the scheme, which RFC 3986 §3.1 defines as case-insensitive.
87///
88/// # Examples
89///
90/// ```
91/// use protocol_droid::domain::mcp::ResourceUri;
92///
93/// // The trailing slash is dropped, so both spellings name one resource.
94/// assert_eq!(
95/// ResourceUri::new("https://example.com/mcp/").unwrap().as_str(),
96/// "https://example.com/mcp",
97/// );
98/// assert_eq!(
99/// ResourceUri::new("https://example.com/mcp").unwrap(),
100/// ResourceUri::new("https://example.com/mcp/").unwrap(),
101/// );
102///
103/// // A default port is dropped too.
104/// assert_eq!(
105/// ResourceUri::new("HTTPS://example.com:443/mcp").unwrap().as_str(),
106/// "https://example.com/mcp",
107/// );
108///
109/// // A non-default port is part of the resource and is kept.
110/// assert_eq!(
111/// ResourceUri::new("https://example.com:8443/mcp").unwrap().as_str(),
112/// "https://example.com:8443/mcp",
113/// );
114/// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
115/// ```
116#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
117pub struct ResourceUri(Arc<str>);
118
119impl Serialize for ResourceUri {
120 /// Serialises as the canonical string, which is the RFC 9728 `resource`
121 /// value verbatim.
122 ///
123 /// Hand-written rather than derived because the inner `Arc<str>` is a
124 /// newtype's implementation detail — a derived impl would make the
125 /// canonical form load-bearing in a second place, and the whole point of
126 /// the type is that there is exactly one spelling of a resource.
127 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
128 serializer.serialize_str(self.as_str())
129 }
130}
131
132impl ResourceUri {
133 /// Canonicalises a resource identifier.
134 ///
135 /// The inherent twin of [`FromStr`], so a caller reading this module does
136 /// not have to know that the parsing rule lives in a trait impl. Both do
137 /// the same thing; `new` exists because the `parse` in a doctest is noise
138 /// when the value came from a config file rather than from user input.
139 ///
140 /// # Errors
141 ///
142 /// The same as [`ResourceUri::from_str`].
143 ///
144 /// # Examples
145 ///
146 /// ```
147 /// use protocol_droid::domain::mcp::ResourceUri;
148 ///
149 /// assert_eq!(
150 /// ResourceUri::new("https://example.com/mcp/")?.as_str(),
151 /// "https://example.com/mcp",
152 /// );
153 /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
154 /// ```
155 pub fn new(value: &str) -> Result<Self, ResourceUriError> {
156 value.parse()
157 }
158
159 /// The canonical string, which is also the `resource` field and the
160 /// expected `aud`.
161 #[must_use]
162 pub fn as_str(&self) -> &str {
163 &self.0
164 }
165
166 /// Builds a canonical identifier from a base origin and a mounted path.
167 ///
168 /// The join is done here rather than by the caller because the two halves
169 /// each arrive from somewhere that gets slash handling wrong: a config file
170 /// writes a trailing slash on the origin, and the mount point is an
171 /// [`Endpoint`](crate::adapters::primary::transport::http::Endpoint) that already requires a leading one.
172 ///
173 /// # Errors
174 ///
175 /// Whatever [`Self::new`] returns for the joined string.
176 ///
177 /// # Examples
178 ///
179 /// ```
180 /// use protocol_droid::domain::mcp::ResourceUri;
181 ///
182 /// let uri = ResourceUri::from_parts("https://example.com/", "/mcp")?;
183 /// assert_eq!(uri.as_str(), "https://example.com/mcp");
184 ///
185 /// // An origin with no path still produces a usable identifier.
186 /// let root = ResourceUri::from_parts("https://example.com", "")?;
187 /// assert_eq!(root.as_str(), "https://example.com");
188 /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
189 /// ```
190 pub fn from_parts(origin: &str, path: &str) -> Result<Self, ResourceUriError> {
191 let base = origin.trim_end_matches('/');
192 // A path of `/` means the resource *is* the origin; anything else is
193 // appended to the origin with exactly one slash between them.
194 let trimmed = path.trim_matches('/');
195 let joined = if trimmed.is_empty() {
196 base.to_owned()
197 } else {
198 format!("{base}/{trimmed}")
199 };
200 Self::new(&joined)
201 }
202
203 /// The RFC 9728 path this resource's metadata is published at.
204 ///
205 /// The resource's own path appended to [`WELL_KNOWN`], which is the
206 /// path-inserted form from RFC 9728 §3.1. A resource at the origin root
207 /// publishes at exactly [`WELL_KNOWN`].
208 ///
209 /// # Examples
210 ///
211 /// ```
212 /// use protocol_droid::domain::mcp::ResourceUri;
213 ///
214 /// assert_eq!(
215 /// ResourceUri::new("https://example.com/mcp").unwrap().metadata_path(),
216 /// "/.well-known/oauth-protected-resource/mcp",
217 /// );
218 /// assert_eq!(
219 /// ResourceUri::new("https://example.com").unwrap().metadata_path(),
220 /// "/.well-known/oauth-protected-resource",
221 /// );
222 /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
223 /// ```
224 #[must_use]
225 pub fn metadata_path(&self) -> String {
226 let Some(path) = self.path() else {
227 return WELL_KNOWN.to_owned();
228 };
229 format!("{WELL_KNOWN}{path}")
230 }
231
232 /// The absolute URL a `WWW-Authenticate` challenge points at.
233 ///
234 /// # Errors
235 ///
236 /// [`ResourceUriError::NoOrigin`] when this identifier has no origin, which
237 /// [`Self::new`] rules out — so this cannot fire from a `ResourceUri` that
238 /// was built through the constructor, and exists only so the return type is
239 /// honest about the fallback rather than panicking.
240 ///
241 /// # Examples
242 ///
243 /// ```
244 /// use protocol_droid::domain::mcp::ResourceUri;
245 ///
246 /// let uri = ResourceUri::new("https://example.com/mcp")?;
247 /// assert_eq!(
248 /// uri.metadata_url()?,
249 /// "https://example.com/.well-known/oauth-protected-resource/mcp",
250 /// );
251 /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
252 /// ```
253 pub fn metadata_url(&self) -> Result<String, ResourceUriError> {
254 let origin = self.origin().ok_or(ResourceUriError::NoOrigin)?;
255 Ok(format!("{origin}{}", self.metadata_path()))
256 }
257
258 /// The origin — scheme, host and any non-default port — with no path.
259 fn origin(&self) -> Option<&str> {
260 let after_scheme = self.0.find("://")? + 3;
261 let end = self.0[after_scheme..]
262 .find('/')
263 .map_or(self.0.len(), |offset| after_scheme + offset);
264 Some(&self.0[..end])
265 }
266
267 /// The path component, without the origin and without a trailing slash.
268 fn path(&self) -> Option<&str> {
269 let after_scheme = self.0.find("://")? + 3;
270 let slash = self.0[after_scheme..].find('/')? + after_scheme;
271 Some(&self.0[slash..])
272 }
273}
274
275impl FromStr for ResourceUri {
276 type Err = ResourceUriError;
277
278 /// # Errors
279 ///
280 /// [`ResourceUriError::Empty`] for a blank value, and
281 /// [`ResourceUriError::NotAbsolute`] when there is no `scheme://`. RFC 9728
282 /// §2 requires `resource` to be an absolute URI, so a relative one is not
283 /// a resource identifier and cannot be compared against an `aud`.
284 ///
285 /// A trailing slash and a default port are canonicalised rather than
286 /// refused — see the type docs for why that is the right call.
287 ///
288 /// # Examples
289 ///
290 /// ```
291 /// use protocol_droid::domain::mcp::ResourceUri;
292 ///
293 /// assert!(ResourceUri::new("example.com/mcp").is_err());
294 /// assert!(ResourceUri::new(" ").is_err());
295 /// assert!(ResourceUri::new("https://example.com/mcp").is_ok());
296 /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
297 /// ```
298 fn from_str(value: &str) -> Result<Self, Self::Err> {
299 let trimmed = value.trim();
300 if trimmed.is_empty() {
301 return Err(ResourceUriError::Empty);
302 }
303
304 let Some(scheme_end) = trimmed.find("://") else {
305 return Err(ResourceUriError::NotAbsolute);
306 };
307 let scheme = &trimmed[..scheme_end];
308 if scheme.is_empty()
309 || !scheme
310 .bytes()
311 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.'))
312 {
313 return Err(ResourceUriError::NotAbsolute);
314 }
315
316 // RFC 3986 §3.1: the scheme is case-insensitive. Only the scheme —
317 // see the type docs on why the host is not folded.
318 let mut canonical = String::with_capacity(trimmed.len());
319 canonical.push_str(&scheme.to_ascii_lowercase());
320 canonical.push_str("://");
321
322 let rest = &trimmed[scheme_end + 3..];
323 let (authority, path) = rest
324 .find('/')
325 .map_or((rest, ""), |slash| (&rest[..slash], &rest[slash..]));
326 if authority.is_empty() {
327 return Err(ResourceUriError::NoAuthority);
328 }
329 canonical.push_str(&drop_default_port(authority, scheme));
330 // Exactly one trailing slash is dropped, and only from the end of a
331 // non-empty path, so `/mcp//` keeps the second slash rather than
332 // quietly collapsing a path that is genuinely different.
333 let path = path.strip_suffix('/').unwrap_or(path);
334 canonical.push_str(path);
335
336 Ok(Self(Arc::from(canonical.as_str())))
337 }
338}
339
340/// `authority` without its port when the port is the scheme's default.
341///
342/// Compares the scheme case-insensitively because the caller passes the raw
343/// scheme, not the canonicalised one, and `HTTPS://example.com:443` has to
344/// fold the same way `https://example.com:443` does.
345///
346/// The port separator is *not* always the last colon. In an IPv6 literal every
347/// group is separated by one — `[::1]:8443` has three — so a `rsplit(':')` cuts
348/// the host in half and then compares whatever followed to the default port.
349/// The closing bracket is what marks the end of the host in that case, and
350/// getting this wrong silently drops or keeps the port on an address nobody
351/// writes as a hostname.
352fn drop_default_port(authority: &str, scheme: &str) -> String {
353 let default = match scheme.to_ascii_lowercase().as_str() {
354 "https" => Some("443"),
355 "http" => Some("80"),
356 _ => None,
357 };
358
359 // For a bracketed literal the host is everything through the `]`, and the
360 // separator is the first `:` after it. `find` on the tail returns an offset
361 // *into the tail*, so the addition that steps past the bracket has to happen
362 // inside the closure — chained after it, `close` is gone and only the
363 // wrong-index number is left. For anything else the last `:` separates.
364 let separator = if authority.starts_with('[') {
365 authority.find(']').and_then(|close| {
366 authority[close + 1..]
367 .find(':')
368 .map(|in_tail| close + 1 + in_tail)
369 })
370 } else {
371 authority.rfind(':')
372 };
373
374 let Some(separator) = separator else {
375 return authority.to_owned();
376 };
377 debug_assert!(authority.is_char_boundary(separator));
378 let (host, port) = authority.split_at(separator);
379 let port = &port[1..];
380
381 if default == Some(port) {
382 return host.to_owned();
383 }
384 authority.to_owned()
385}
386
387impl TryFrom<&str> for ResourceUri {
388 type Error = ResourceUriError;
389
390 /// # Errors
391 ///
392 /// The same as [`ResourceUri::new`].
393 fn try_from(value: &str) -> Result<Self, Self::Error> {
394 value.parse()
395 }
396}
397
398impl fmt::Display for ResourceUri {
399 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400 f.write_str(&self.0)
401 }
402}
403
404/// Why a string is not a usable resource identifier.
405#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
406pub enum ResourceUriError {
407 /// The value was blank.
408 #[error("the resource identifier is empty")]
409 Empty,
410
411 /// There was no `scheme://`, or the scheme was not a legal one.
412 ///
413 /// RFC 9728 §2 requires an absolute URI, so this is a refusal rather than
414 /// something to guess at: a relative identifier cannot be compared against
415 /// a token's `aud`, which is the only thing it is for.
416 #[error("the resource identifier is not an absolute URI with an http(s) scheme")]
417 NotAbsolute,
418
419 /// The authority was empty between `://` and the first `/`.
420 #[error("the resource identifier names no host")]
421 NoAuthority,
422
423 /// The derived metadata path was not usable.
424 ///
425 /// Unreachable through [`ResourceUri::new`] — the constructor refuses
426 /// anything without an authority — so it exists to give
427 /// [`ResourceUri::metadata_url`] an error type rather than a panic.
428 #[error("the resource identifier has no origin to publish metadata under")]
429 NoOrigin,
430}
431
432/// An RFC 9728 protected-resource metadata document.
433///
434/// What an MCP server publishes to tell a client which authorization servers can
435/// issue tokens for it, and which scopes those tokens should carry.
436///
437/// # Wire form
438///
439/// Field names are the `snake_case` ones RFC 9728 §2 defines — unlike the rest of
440/// MCP's wire types, so this one derives `Serialize` rather than renaming to
441/// camelCase. `skip_serializing_if` is not an optimisation: §3.2 says a
442/// parameter with zero values MUST be omitted, so an empty
443/// `authorization_servers` is absent rather than `[]`. `resource` is the one
444/// field that is always present.
445#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
446pub struct ResourceMetadata {
447 /// This server's canonical resource identifier. Required by RFC 9728 §2.
448 resource: ResourceUri,
449
450 /// Issuer URLs of the authorization servers that can issue tokens for this
451 /// resource.
452 ///
453 /// A `Vec` rather than a set because RFC 9728 §2 defines an array and the
454 /// order is not significant, but deduplication is still the server's
455 /// decision — publishing the same issuer twice is odd rather than wrong,
456 /// and refusing to publish it would be this type being clever with
457 /// somebody's configuration.
458 #[serde(skip_serializing_if = "Vec::is_empty")]
459 authorization_servers: Vec<Arc<str>>,
460
461 /// Scopes a token for this resource should carry.
462 #[serde(skip_serializing_if = "Vec::is_empty")]
463 scopes_supported: Vec<Arc<str>>,
464
465 /// How a bearer token may be presented. Always [`BearerMethod::Header`].
466 #[serde(skip_serializing_if = "Vec::is_empty")]
467 bearer_methods_supported: Vec<BearerMethod>,
468
469 /// Human-readable name for the resource, from RFC 9728 §2.
470 #[serde(skip_serializing_if = "Option::is_none")]
471 resource_name: Option<Arc<str>>,
472}
473
474impl ResourceMetadata {
475 /// A document for `resource`, with the mandatory `resource` field and
476 /// nothing optional.
477 ///
478 /// # Examples
479 ///
480 /// ```
481 /// use protocol_droid::domain::mcp::{ResourceMetadata, ResourceUri};
482 ///
483 /// let metadata = ResourceMetadata::new(ResourceUri::new("https://example.com/mcp")?);
484 /// assert_eq!(metadata.resource().as_str(), "https://example.com/mcp");
485 /// assert!(metadata.authorization_servers().is_empty());
486 /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
487 /// ```
488 #[must_use]
489 pub fn new(resource: ResourceUri) -> Self {
490 Self {
491 resource,
492 authorization_servers: Vec::new(),
493 scopes_supported: Vec::new(),
494 bearer_methods_supported: vec![BearerMethod::Header],
495 resource_name: None,
496 }
497 }
498
499 /// Names an authorization server that can issue tokens for this resource.
500 #[must_use]
501 pub fn with_authorization_server(mut self, issuer: impl Into<Arc<str>>) -> Self {
502 self.authorization_servers.push(issuer.into());
503 self
504 }
505
506 /// Declares a scope a token may carry.
507 #[must_use]
508 pub fn with_scope(mut self, scope: impl Into<Arc<str>>) -> Self {
509 self.scopes_supported.push(scope.into());
510 self
511 }
512
513 /// Sets the human-readable name.
514 #[must_use]
515 pub fn with_name(mut self, name: impl Into<Arc<str>>) -> Self {
516 self.resource_name = Some(name.into());
517 self
518 }
519
520 /// The canonical resource identifier, and the audience a token must carry.
521 #[must_use]
522 pub const fn resource(&self) -> &ResourceUri {
523 &self.resource
524 }
525
526 /// The authorization servers this resource names.
527 #[must_use]
528 pub fn authorization_servers(&self) -> &[Arc<str>] {
529 &self.authorization_servers
530 }
531
532 /// The scopes this resource declares.
533 #[must_use]
534 pub fn scopes_supported(&self) -> &[Arc<str>] {
535 &self.scopes_supported
536 }
537
538 /// How a bearer token may be presented.
539 #[must_use]
540 pub fn bearer_methods_supported(&self) -> &[BearerMethod] {
541 &self.bearer_methods_supported
542 }
543
544 /// The `WWW-Authenticate` challenge for a request that was refused.
545 ///
546 /// RFC 9728 §5.1: the challenge carries `resource_metadata` so the client
547 /// knows where to discover the authorization server, which is the whole
548 /// reason a server publishes the document.
549 ///
550 /// # Errors
551 ///
552 /// [`ResourceUriError::NoOrigin`] when the resource identifier has no
553 /// origin, which the constructor rules out.
554 ///
555 /// # Examples
556 ///
557 /// ```
558 /// use protocol_droid::domain::mcp::{ResourceMetadata, ResourceUri};
559 ///
560 /// let metadata = ResourceMetadata::new(ResourceUri::new("https://example.com/mcp")?);
561 /// assert_eq!(
562 /// metadata.challenge()?,
563 /// "Bearer resource_metadata=\"https://example.com/.well-known/oauth-protected-resource/mcp\"",
564 /// );
565 /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
566 /// ```
567 pub fn challenge(&self) -> Result<String, ResourceUriError> {
568 Ok(format!(
569 "Bearer resource_metadata=\"{}\"",
570 self.resource.metadata_url()?
571 ))
572 }
573}
574
575#[cfg(test)]
576#[path = "resource_metadata/tests.rs"]
577mod tests;
578
579#[cfg(test)]
580mod wire_tests {
581 use super::{ResourceMetadata, ResourceUri, ResourceUriError};
582
583 /// The fallible calls in this module answer unrelated error types, so the
584 /// test returns a `Result` over this rather than unwrapping. Each variant
585 /// says what went wrong, because a bare "test failed" hides which of the
586 /// two steps it was.
587 /// The first element of a JSON array parameter, or `None` when the value is
588 /// absent or is not an array. Keeps the assertions from indexing.
589 fn first_element(value: &serde_json::Value) -> Option<serde_json::Value> {
590 value.as_array().and_then(|values| values.first().cloned())
591 }
592
593 impl core::fmt::Display for Failure {
594 /// Reads both payloads, so a failure says *which* step broke rather
595 /// than only that something did.
596 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
597 match self {
598 Self::Uri(e) => write!(f, "not a legal resource identifier: {e}"),
599 Self::Json(e) => write!(f, "document did not serialise: {e}"),
600 }
601 }
602 }
603
604 impl std::error::Error for Failure {}
605
606 #[derive(Debug)]
607 enum Failure {
608 /// A literal in the test was not a legal resource identifier.
609 Uri(ResourceUriError),
610 /// The document did not serialise, which would be a bug in the derive.
611 Json(serde_json::Error),
612 }
613
614 #[test]
615 fn emits_rfc_9728_field_names_and_omits_empty_parameters() -> Result<(), Failure> {
616 let full = ResourceMetadata::new(
617 ResourceUri::new("https://example.com/mcp").map_err(Failure::Uri)?,
618 )
619 .with_authorization_server("https://auth.example.com")
620 .with_scope("files:read")
621 .with_name("Example");
622 let full_json: serde_json::Value = serde_json::to_value(&full).map_err(Failure::Json)?;
623 // `get` rather than indexing throughout: a wrong shape then fails an
624 // assertion instead of panicking inside the test.
625 let first = |key: &str| full_json.get(key).and_then(first_element);
626 assert_eq!(
627 full_json.get("resource"),
628 Some(&serde_json::json!("https://example.com/mcp"))
629 );
630 assert_eq!(
631 first("authorization_servers"),
632 Some(serde_json::json!("https://auth.example.com"))
633 );
634 assert_eq!(
635 first("scopes_supported"),
636 Some(serde_json::json!("files:read"))
637 );
638 assert_eq!(
639 first("bearer_methods_supported"),
640 Some(serde_json::json!("header"))
641 );
642 assert_eq!(
643 full_json.get("resource_name"),
644 Some(&serde_json::json!("Example"))
645 );
646
647 let bare =
648 ResourceMetadata::new(ResourceUri::new("https://example.com").map_err(Failure::Uri)?);
649 let bare_json: serde_json::Value = serde_json::to_value(&bare).map_err(Failure::Json)?;
650 assert_eq!(
651 bare_json.get("resource"),
652 Some(&serde_json::json!("https://example.com"))
653 );
654 for absent in ["authorization_servers", "scopes_supported", "resource_name"] {
655 assert!(
656 bare_json.get(absent).is_none(),
657 "RFC 9728 3.2 requires {absent} be omitted when empty, got {bare_json}"
658 );
659 }
660 // `bearer_methods_supported` is the exception: `new` defaults it to
661 // `["header"]` because this crate reads the token from the
662 // `Authorization` header and nowhere else, so it is never empty and
663 // always published.
664 assert_eq!(
665 bare_json
666 .get("bearer_methods_supported")
667 .and_then(first_element),
668 Some(serde_json::json!("header"))
669 );
670 Ok(())
671 }
672}