Skip to main content

doxa_docs/
contribution.rs

1//! [`LayerContribution`] — bundle of OpenAPI metadata that a tower
2//! [`Layer`](tower::Layer) adds to the routes it covers, plus the
3//! [`DocumentedLayer`] trait that lets a layer announce its own
4//! contribution.
5//!
6//! A single layer can contribute headers, extra response codes,
7//! security requirements, and tags in one declaration. Apply by
8//! calling [`crate::OpenApiRouterExt::layer_documented`] (which reads
9//! the contribution off the layer via [`DocumentedLayer`]) or, for
10//! callers that hold an [`utoipa::openapi::OpenApi`] directly, by
11//! calling [`apply_contribution`] explicitly.
12
13use utoipa::openapi::content::Content;
14use utoipa::openapi::path::Operation;
15use utoipa::openapi::response::{Response, ResponseBuilder};
16use utoipa::openapi::security::SecurityRequirement;
17use utoipa::openapi::{Ref, RefOr};
18
19use crate::headers::{apply_headers_to_operation, HeaderParam};
20
21/// What a layer contributes to the OpenAPI contract for the
22/// operations it covers. Build with [`LayerContribution::new`] and
23/// the chainable `with_*` setters; empty fields are zero-cost.
24#[derive(Clone, Debug, Default)]
25pub struct LayerContribution {
26    pub(crate) headers: Vec<HeaderParam>,
27    pub(crate) responses: Vec<ResponseContribution>,
28    pub(crate) security: Vec<SecurityContribution>,
29    pub(crate) tags: Vec<String>,
30    pub(crate) badges: Vec<BadgeContribution>,
31}
32
33/// One badge entry the layer attaches to every operation it covers.
34/// Surfaces in doc UIs that render the `x-badges` vendor extension
35/// (Scalar): a colored chip with the supplied name.
36#[derive(Clone, Debug)]
37pub struct BadgeContribution {
38    /// Badge name rendered in the UI. Serialized as the `name` key in
39    /// the emitted `x-badges` entry to match Scalar's schema.
40    pub name: String,
41    /// Badge color. CSS color values are accepted by Scalar (keyword,
42    /// hex, rgb, hsl).
43    pub color: String,
44}
45
46impl BadgeContribution {
47    /// Build a badge contribution with the given name and color.
48    pub fn new(name: impl Into<String>, color: impl Into<String>) -> Self {
49        Self {
50            name: name.into(),
51            color: color.into(),
52        }
53    }
54}
55
56/// One extra response status the layer can return (e.g. 401 from auth
57/// middleware, 429 from a rate limiter). Skipped on operations that
58/// already declare a response with the same status — handler-level
59/// declarations always win.
60#[derive(Clone, Debug)]
61pub struct ResponseContribution {
62    /// Status code as a string (e.g. `"401"`, `"default"`).
63    pub status: String,
64    /// Human description rendered in the docs UI.
65    pub description: String,
66    /// Optional `$ref` path for the response body schema (e.g.
67    /// `"#/components/schemas/ApiErrorBody"`). Set via
68    /// [`ResponseContribution::with_schema_ref`].
69    pub schema_ref: Option<String>,
70}
71
72impl ResponseContribution {
73    /// Build a response contribution with the given status and
74    /// description.
75    pub fn new(status: impl Into<String>, description: impl Into<String>) -> Self {
76        Self {
77            status: status.into(),
78            description: description.into(),
79            schema_ref: None,
80        }
81    }
82
83    /// Convenience constructor for the standard 401 Unauthorized
84    /// response.
85    pub fn unauthorized() -> Self {
86        Self::new("401", "Authentication required")
87    }
88
89    /// Convenience constructor for the standard 403 Forbidden
90    /// response.
91    pub fn forbidden() -> Self {
92        Self::new("403", "Permission denied")
93    }
94
95    /// Set the `$ref` path for the response body schema.
96    pub fn with_schema_ref(mut self, ref_path: impl Into<String>) -> Self {
97        self.schema_ref = Some(ref_path.into());
98        self
99    }
100
101    /// Build the utoipa [`Response`] for this contribution.
102    pub(crate) fn to_response(&self) -> Response {
103        let mut b = ResponseBuilder::new().description(self.description.clone());
104        if let Some(ref_path) = &self.schema_ref {
105            b = b.content(
106                "application/json",
107                Content::new(Some(RefOr::Ref(Ref::new(ref_path.clone())))),
108            );
109        }
110        b.build()
111    }
112}
113
114/// One security requirement entry the layer enforces. References a
115/// scheme that has been registered with
116/// [`crate::ApiDocBuilder::bearer_security`] or
117/// [`crate::ApiDocBuilder::security_scheme`] — a dangling reference
118/// produces an invalid spec, so make sure the scheme name matches.
119#[derive(Clone, Debug)]
120pub struct SecurityContribution {
121    /// Name of the security scheme as registered on the
122    /// [`crate::ApiDocBuilder`].
123    pub scheme: String,
124    /// Required scopes. Empty for non-OAuth schemes.
125    pub scopes: Vec<String>,
126}
127
128impl SecurityContribution {
129    /// Build a security contribution naming the scheme to enforce.
130    /// No scopes by default — use [`SecurityContribution::with_scopes`]
131    /// for OAuth flows.
132    pub fn new(scheme: impl Into<String>) -> Self {
133        Self {
134            scheme: scheme.into(),
135            scopes: Vec::new(),
136        }
137    }
138
139    /// Set the OAuth scopes required by this requirement.
140    pub fn with_scopes(mut self, scopes: impl IntoIterator<Item = String>) -> Self {
141        self.scopes = scopes.into_iter().collect();
142        self
143    }
144}
145
146impl LayerContribution {
147    /// Construct an empty contribution.
148    pub fn new() -> Self {
149        Self::default()
150    }
151
152    /// Add a single header to the contribution.
153    pub fn with_header(mut self, h: HeaderParam) -> Self {
154        self.headers.push(h);
155        self
156    }
157
158    /// Add multiple headers to the contribution.
159    pub fn with_headers(mut self, hs: impl IntoIterator<Item = HeaderParam>) -> Self {
160        self.headers.extend(hs);
161        self
162    }
163
164    /// Add a response status the layer can produce.
165    pub fn with_response(mut self, r: ResponseContribution) -> Self {
166        self.responses.push(r);
167        self
168    }
169
170    /// Add a security requirement the layer enforces.
171    pub fn with_security(mut self, s: SecurityContribution) -> Self {
172        self.security.push(s);
173        self
174    }
175
176    /// Add a tag the layer applies to its operations.
177    pub fn with_tag(mut self, t: impl Into<String>) -> Self {
178        self.tags.push(t.into());
179        self
180    }
181
182    /// Add a badge the layer attaches to every operation it covers.
183    /// Renders as a colored chip on doc UIs that surface
184    /// `x-badges` (Scalar).
185    pub fn with_badge(mut self, b: BadgeContribution) -> Self {
186        self.badges.push(b);
187        self
188    }
189
190    /// Whether the contribution adds nothing to operations.
191    pub fn is_empty(&self) -> bool {
192        self.headers.is_empty()
193            && self.responses.is_empty()
194            && self.security.is_empty()
195            && self.tags.is_empty()
196            && self.badges.is_empty()
197    }
198
199    /// Merge another contribution into this one. Order-preserving;
200    /// dedup happens at apply time, not merge time.
201    pub fn merge(&mut self, other: LayerContribution) {
202        self.headers.extend(other.headers);
203        self.responses.extend(other.responses);
204        self.security.extend(other.security);
205        self.tags.extend(other.tags);
206        self.badges.extend(other.badges);
207    }
208}
209
210/// A tower [`Layer`](tower::Layer) that declares its own OpenAPI
211/// contribution.
212///
213/// Implement this on the same struct that implements
214/// [`tower::Layer`](tower::Layer), so call sites can use
215/// [`crate::OpenApiRouterExt::layer_documented`] with a single
216/// argument and the contribution is inferred from the layer's type.
217///
218/// ```rust,ignore
219/// use doxa::{
220///     DocumentedLayer, HeaderParam, LayerContribution,
221///     ResponseContribution, SecurityContribution,
222/// };
223///
224/// pub struct MyAuthLayer { /* … */ }
225/// impl<S> tower::Layer<S> for MyAuthLayer {
226///     /* … */
227/// #   type Service = S;
228/// #   fn layer(&self, inner: S) -> Self::Service { inner }
229/// }
230/// impl DocumentedLayer for MyAuthLayer {
231///     fn contribution(&self) -> LayerContribution {
232///         LayerContribution::new()
233///             .with_header(HeaderParam::required("Authorization"))
234///             .with_response(ResponseContribution::unauthorized())
235///             .with_security(SecurityContribution::new("bearer"))
236///     }
237/// }
238/// ```
239pub trait DocumentedLayer {
240    /// Return the OpenAPI contribution this layer adds to every
241    /// operation it covers. Called once at router-build time, on
242    /// every [`crate::OpenApiRouterExt::layer_documented`] invocation.
243    fn contribution(&self) -> LayerContribution;
244}
245
246/// Apply a contribution to a single operation. Each kind dedupes
247/// against existing entries so handler-level declarations always win
248/// and repeated layer applications are idempotent.
249pub(crate) fn apply_contribution_to_operation(op: &mut Operation, c: &LayerContribution) {
250    apply_headers_to_operation(op, &c.headers);
251
252    // Responses — skip statuses the handler already declared.
253    for r in &c.responses {
254        if op.responses.responses.contains_key(&r.status) {
255            continue;
256        }
257        op.responses
258            .responses
259            .insert(r.status.clone(), RefOr::T(r.to_response()));
260    }
261
262    // Security — merge by scheme. Multiple contributions for the same
263    // scheme are combined into a single SecurityRequirement with the
264    // union of their scopes, rather than producing duplicate entries.
265    if !c.security.is_empty() {
266        let security = op.security.get_or_insert_with(Vec::new);
267        for s in &c.security {
268            merge_security_requirement(security, &s.scheme, &s.scopes);
269        }
270    }
271
272    // Tags — additive, dedup on string equality.
273    if !c.tags.is_empty() {
274        let tags = op.tags.get_or_insert_with(Vec::new);
275        for t in &c.tags {
276            if !tags.iter().any(|existing| existing == t) {
277                tags.push(t.clone());
278            }
279        }
280    }
281
282    // Badges — append via the shared `x-badges` writer; dedup by name.
283    for b in &c.badges {
284        apply_badge_to_operation(op, &b.name, &b.color);
285    }
286}
287
288/// Merge a security requirement into `security`, combining scopes by
289/// scheme. If an entry for `scheme` already exists, any new `scopes`
290/// are appended to it. Otherwise a new entry is created.
291///
292/// This produces one `SecurityRequirement` per scheme with the union
293/// of all contributed scopes, rather than duplicating entries.
294fn merge_security_requirement(
295    security: &mut Vec<SecurityRequirement>,
296    scheme: &str,
297    scopes: &[String],
298) {
299    // SecurityRequirement's inner BTreeMap is private, so we round-trip
300    // through serde to inspect existing entries for this scheme.
301    if let Some(pos) = security.iter().position(|req| {
302        serde_json::to_value(req)
303            .ok()
304            .and_then(|v| v.as_object().cloned())
305            .is_some_and(|map| map.contains_key(scheme))
306    }) {
307        // Found an existing entry for this scheme — merge scopes.
308        if !scopes.is_empty() {
309            let existing = &security[pos];
310            let mut map: std::collections::BTreeMap<String, Vec<String>> =
311                serde_json::from_value(serde_json::to_value(existing).unwrap_or_default())
312                    .unwrap_or_default();
313            if let Some(existing_scopes) = map.get_mut(scheme) {
314                for scope in scopes {
315                    if !existing_scopes.contains(scope) {
316                        existing_scopes.push(scope.clone());
317                    }
318                }
319            }
320            // Rebuild the SecurityRequirement with merged scopes.
321            let merged_scopes = map.get(scheme).cloned().unwrap_or_default();
322            security[pos] = SecurityRequirement::new(scheme.to_string(), merged_scopes);
323        }
324        // If scopes is empty, the existing scoped entry already subsumes
325        // the bare requirement — nothing to do.
326    } else {
327        security.push(SecurityRequirement::new(
328            scheme.to_string(),
329            scopes.to_vec(),
330        ));
331    }
332}
333
334/// Record a per-operation permission requirement on `op`, emitting:
335///
336/// - A standard [`SecurityRequirement`] referencing `scheme` with the supplied
337///   `scope` — so OpenAPI client codegen sees a required OAuth scope and
338///   threads it through to the token request.
339/// - An `x-required-permissions` vendor extension entry containing `display` —
340///   a machine-readable list of the actions a request must satisfy, available
341///   to tooling that walks the spec.
342/// - An `x-badges` vendor extension entry shaped for Scalar's native badge
343///   renderer (`{name, color}`) — surfaces the permission as a colored chip on
344///   each operation header, no markdown injection. Scalar accepts any CSS color
345///   value; we use the Scalar CSS custom property `var(--scalar-color-accent)`
346///   so badges adopt whatever accent color the active theme defines.
347///
348/// All three writes are idempotent — repeated calls with the same
349/// arguments don't duplicate. The `scope` is the canonical machine
350/// identifier (e.g. an OAuth2 scope string); `display` is the human
351/// label rendered in badges and in `x-required-permissions`.
352pub fn record_required_permission(op: &mut Operation, scheme: &str, scope: &str, display: &str) {
353    use utoipa::openapi::extensions::ExtensionsBuilder;
354
355    let security = op.security.get_or_insert_with(Vec::new);
356    merge_security_requirement(security, scheme, &[scope.to_string()]);
357
358    // Round-trip through `serde_json::Value` because
359    // `utoipa::openapi::extensions::Extensions` does not expose its
360    // inner map by reference. Idempotent on `display`.
361    let existing_ext = op
362        .extensions
363        .as_ref()
364        .and_then(|ext| serde_json::to_value(ext).ok());
365
366    let mut perms = extract_extension_array(existing_ext.as_ref(), "x-required-permissions");
367    let perm_entry = serde_json::Value::String(display.to_string());
368    if !perms.contains(&perm_entry) {
369        perms.push(perm_entry);
370    }
371
372    let mut badges = extract_extension_array(existing_ext.as_ref(), "x-badges");
373    let badge_entry = serde_json::json!({
374        "name": display,
375        "color": "var(--scalar-color-accent)",
376    });
377    let already_badged = badges
378        .iter()
379        .any(|b| b.get("name") == badge_entry.get("name"));
380    if !already_badged {
381        badges.push(badge_entry);
382    }
383
384    let ext = ExtensionsBuilder::new()
385        .add("x-required-permissions", serde_json::Value::Array(perms))
386        .add("x-badges", serde_json::Value::Array(badges))
387        .build();
388    match op.extensions.as_mut() {
389        Some(existing) => existing.merge(ext),
390        None => op.extensions = Some(ext),
391    }
392}
393
394/// Append an `x-badges` entry shaped for Scalar's native badge
395/// renderer (`{name, color}`) to a single operation. Idempotent on
396/// `name` — calling twice with the same name leaves a single
397/// badge entry. `color` accepts any CSS color value (keyword, hex,
398/// rgb, hsl, or a Scalar CSS custom property like
399/// `var(--scalar-color-accent)`).
400///
401/// Applied internally when a [`LayerContribution`] carries one or
402/// more [`BadgeContribution`] entries — the right tool when the
403/// gating signal lives at a layer/middleware level rather than on
404/// a per-extractor basis (e.g. an admin-only route group behind a
405/// tower `Layer` that implements [`DocumentedLayer`] with a badge
406/// contribution).
407pub fn apply_badge_to_operation(op: &mut Operation, name: &str, color: &str) {
408    use utoipa::openapi::extensions::ExtensionsBuilder;
409
410    let existing_ext = op
411        .extensions
412        .as_ref()
413        .and_then(|ext| serde_json::to_value(ext).ok());
414    let mut badges = extract_extension_array(existing_ext.as_ref(), "x-badges");
415    let entry = serde_json::json!({ "name": name, "color": color });
416    let already = badges.iter().any(|b| b.get("name") == entry.get("name"));
417    if !already {
418        badges.push(entry);
419    }
420    let ext = ExtensionsBuilder::new()
421        .add("x-badges", serde_json::Value::Array(badges))
422        .build();
423    match op.extensions.as_mut() {
424        Some(existing) => existing.merge(ext),
425        None => op.extensions = Some(ext),
426    }
427}
428
429/// Pull a `Vec<Value>` out of a serialized [`Extensions`] map at
430/// `key`, returning an empty vec when the key is missing or not an
431/// array. Used by [`record_required_permission`] to merge into
432/// existing entries idempotently.
433fn extract_extension_array(
434    serialized: Option<&serde_json::Value>,
435    key: &str,
436) -> Vec<serde_json::Value> {
437    serialized
438        .and_then(|v| match v {
439            serde_json::Value::Object(map) => map.get(key).and_then(|v| v.as_array().cloned()),
440            _ => None,
441        })
442        .unwrap_or_default()
443}
444
445/// Apply a contribution to **every** operation in `openapi`. Public
446/// so callers who hold an [`utoipa::openapi::OpenApi`] directly can
447/// also annotate it without going through
448/// [`crate::OpenApiRouterExt`].
449pub fn apply_contribution(openapi: &mut utoipa::openapi::OpenApi, c: &LayerContribution) {
450    if c.is_empty() {
451        return;
452    }
453    for path_item in openapi.paths.paths.values_mut() {
454        for op in path_item_operations_mut(path_item) {
455            apply_contribution_to_operation(op, c);
456        }
457    }
458}
459
460/// Iterate the eight HTTP-method [`Operation`] slots on a
461/// [`utoipa::openapi::PathItem`] by shared reference.
462pub(crate) fn path_item_operations(
463    path_item: &utoipa::openapi::path::PathItem,
464) -> impl Iterator<Item = &Operation> {
465    [
466        path_item.get.as_ref(),
467        path_item.put.as_ref(),
468        path_item.post.as_ref(),
469        path_item.delete.as_ref(),
470        path_item.options.as_ref(),
471        path_item.head.as_ref(),
472        path_item.patch.as_ref(),
473        path_item.trace.as_ref(),
474    ]
475    .into_iter()
476    .flatten()
477}
478
479/// Iterate the eight HTTP-method [`Operation`] slots on a
480/// [`utoipa::openapi::PathItem`]. utoipa models each verb as a
481/// distinct `Option<Operation>` field rather than a map, so we
482/// flatten them here for callers that want method-agnostic mutation.
483pub(crate) fn path_item_operations_mut(
484    path_item: &mut utoipa::openapi::path::PathItem,
485) -> impl Iterator<Item = &mut Operation> {
486    [
487        path_item.get.as_mut(),
488        path_item.put.as_mut(),
489        path_item.post.as_mut(),
490        path_item.delete.as_mut(),
491        path_item.options.as_mut(),
492        path_item.head.as_mut(),
493        path_item.patch.as_mut(),
494        path_item.trace.as_mut(),
495    ]
496    .into_iter()
497    .flatten()
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use utoipa::openapi::path::OperationBuilder;
504    use utoipa::openapi::response::Responses;
505
506    fn empty_op() -> Operation {
507        let mut op = OperationBuilder::new().build();
508        op.responses = Responses::new();
509        op
510    }
511
512    #[test]
513    fn apply_contribution_adds_headers_responses_security_tags_to_operation() {
514        let mut op = empty_op();
515        let c = LayerContribution::new()
516            .with_header(HeaderParam::required("Authorization"))
517            .with_response(ResponseContribution::unauthorized())
518            .with_security(SecurityContribution::new("bearer"))
519            .with_tag("auth");
520
521        apply_contribution_to_operation(&mut op, &c);
522
523        let params = op.parameters.expect("parameters set");
524        assert!(params.iter().any(|p| p.name == "Authorization"));
525        assert!(op.responses.responses.contains_key("401"));
526        let security = op.security.expect("security set");
527        assert_eq!(security.len(), 1);
528        let tags = op.tags.expect("tags set");
529        assert_eq!(tags, vec!["auth".to_string()]);
530    }
531
532    #[test]
533    fn apply_contribution_skips_response_status_already_declared_by_handler() {
534        let mut op = empty_op();
535        op.responses.responses.insert(
536            "401".to_string(),
537            RefOr::T(Response::new("handler-declared 401")),
538        );
539
540        let c = LayerContribution::new().with_response(ResponseContribution::unauthorized());
541        apply_contribution_to_operation(&mut op, &c);
542
543        let resp = op
544            .responses
545            .responses
546            .get("401")
547            .expect("401 still present");
548        match resp {
549            RefOr::T(r) => assert_eq!(r.description, "handler-declared 401"),
550            RefOr::Ref(_) => panic!("expected inline response"),
551        }
552    }
553
554    #[test]
555    fn apply_contribution_dedupes_security_requirement_when_called_twice() {
556        let mut op = empty_op();
557        let c = LayerContribution::new().with_security(SecurityContribution::new("bearer"));
558
559        apply_contribution_to_operation(&mut op, &c);
560        apply_contribution_to_operation(&mut op, &c);
561
562        let security = op.security.expect("security set");
563        assert_eq!(security.len(), 1, "duplicate security requirement");
564    }
565
566    #[test]
567    fn apply_contribution_dedupes_tag() {
568        let mut op = empty_op();
569        let c = LayerContribution::new().with_tag("auth");
570
571        apply_contribution_to_operation(&mut op, &c);
572        apply_contribution_to_operation(&mut op, &c);
573
574        let tags = op.tags.expect("tags set");
575        assert_eq!(tags, vec!["auth".to_string()]);
576    }
577
578    #[test]
579    fn merge_contribution_concatenates_each_kind_in_order() {
580        let mut a = LayerContribution::new()
581            .with_header(HeaderParam::required("X-A"))
582            .with_tag("a");
583        let b = LayerContribution::new()
584            .with_header(HeaderParam::required("X-B"))
585            .with_tag("b");
586        a.merge(b);
587
588        assert_eq!(a.headers.len(), 2);
589        assert_eq!(a.headers[0].name, "X-A");
590        assert_eq!(a.headers[1].name, "X-B");
591        assert_eq!(a.tags, vec!["a".to_string(), "b".to_string()]);
592    }
593
594    #[test]
595    fn default_contribution_is_empty_no_op() {
596        let c = LayerContribution::default();
597        assert!(c.is_empty());
598
599        let mut openapi = utoipa::openapi::OpenApiBuilder::new().build();
600        apply_contribution(&mut openapi, &c);
601        assert!(openapi.paths.paths.is_empty());
602    }
603
604    #[test]
605    fn response_contribution_with_schema_ref_emits_json_content() {
606        let r = ResponseContribution::unauthorized()
607            .with_schema_ref("#/components/schemas/ApiErrorBody");
608        let resp = r.to_response();
609        let content = resp
610            .content
611            .get("application/json")
612            .expect("application/json content present");
613        match &content.schema {
614            Some(RefOr::Ref(_)) => {}
615            _ => panic!("expected $ref schema"),
616        }
617    }
618
619    #[test]
620    fn scoped_extractor_then_bare_layer_produces_single_entry() {
621        let mut op = empty_op();
622
623        // Simulate Require<M> adding a scoped security entry first.
624        record_required_permission(&mut op, "bearer", "widgets.read", "Read widgets");
625
626        // Then AuthLayer contributes a bare scheme entry via layer_documented.
627        let c = LayerContribution::new().with_security(SecurityContribution::new("bearer"));
628        apply_contribution_to_operation(&mut op, &c);
629
630        let security = op.security.expect("security set");
631        assert_eq!(
632            security.len(),
633            1,
634            "bare layer entry should merge into scoped extractor entry"
635        );
636
637        // Verify the single entry has the scope from the extractor.
638        let json = serde_json::to_value(&security[0]).unwrap();
639        let scopes = json.get("bearer").unwrap().as_array().unwrap();
640        assert_eq!(scopes, &[serde_json::json!("widgets.read")]);
641    }
642
643    #[test]
644    fn bare_layer_then_scoped_extractor_produces_single_entry() {
645        let mut op = empty_op();
646
647        // Layer contribution first (bare scheme).
648        let c = LayerContribution::new().with_security(SecurityContribution::new("bearer"));
649        apply_contribution_to_operation(&mut op, &c);
650
651        // Then extractor adds a scoped entry.
652        record_required_permission(&mut op, "bearer", "widgets.write", "Write widgets");
653
654        let security = op.security.expect("security set");
655        assert_eq!(
656            security.len(),
657            1,
658            "scoped extractor entry should merge into bare layer entry"
659        );
660
661        let json = serde_json::to_value(&security[0]).unwrap();
662        let scopes = json.get("bearer").unwrap().as_array().unwrap();
663        assert_eq!(scopes, &[serde_json::json!("widgets.write")]);
664    }
665
666    #[test]
667    fn multiple_scopes_merge_into_single_entry() {
668        let mut op = empty_op();
669
670        record_required_permission(&mut op, "bearer", "widgets.read", "Read");
671        record_required_permission(&mut op, "bearer", "widgets.write", "Write");
672
673        let c = LayerContribution::new().with_security(SecurityContribution::new("bearer"));
674        apply_contribution_to_operation(&mut op, &c);
675
676        let security = op.security.expect("security set");
677        assert_eq!(security.len(), 1);
678
679        let json = serde_json::to_value(&security[0]).unwrap();
680        let scopes = json.get("bearer").unwrap().as_array().unwrap();
681        assert!(scopes.contains(&serde_json::json!("widgets.read")));
682        assert!(scopes.contains(&serde_json::json!("widgets.write")));
683    }
684}