Repository: oxidecomputer/dropshot (Rust)

Issue:
Endpoint macro should support `deprecated`
The openapi spec supports a `boolean` field on the operation object that signifies the operation is deprecated. We should allow that to be specified via the endpoint macro. 

Example

```rust
#[endpoint {
    method = GET,
    path = "/organizations/{organization_name}/projects/{project_name}/instances",
    tags = ["instances"],
    deprecated = true,
}]
```

One thing to consider is that if we deprecate something we should always provide documentation to the user on what to use instead. That could either be baked in to the api by having `deprecated` actually take a string or enforced later via a lint rule. 


Discussion:

Patch:
diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc
index 37f0e898f..4efede614 100644
--- a/CHANGELOG.adoc
+++ b/CHANGELOG.adoc
@@ -17,6 +17,7 @@ https://github.com/oxidecomputer/dropshot/compare/v0.8.0\...HEAD[Full list of co
 
 * https://github.com/oxidecomputer/dropshot/pull/452[#452] Dropshot no longer enables the `slog` cargo features `max_level_trace` and `release_max_level_debug`. Previously, clients were unable to set a release log level of `trace`; now they can. However, clients that did not select their own max log levels will see behavior change from the levels Dropshot was choosing to the default levels of `slog` itself (`debug` for debug builds and `info` for release builds).
 * https://github.com/oxidecomputer/dropshot/pull/451[#451] There are now response types to support 302 ("Found"), 303 ("See Other"), and 307 ("Temporary Redirect") HTTP response codes.  See `HttpResponseFound`, `HttpResponseSeeOther`, and `HttpResponseTemporaryRedirect`.
+* https://github.com/oxidecomputer/dropshot/pull/503[#503] Add an optional `deprecated` field to the `#[endpoint]` macro.
 
 == 0.8.0 (released 2022-09-09)
 
diff --git a/dropshot/src/api_description.rs b/dropshot/src/api_description.rs
index e472c1879..e4771bc32 100644
--- a/dropshot/src/api_description.rs
+++ b/dropshot/src/api_description.rs
@@ -47,6 +47,7 @@ pub struct ApiEndpoint<Context: ServerContext> {
     pub tags: Vec<String>,
     pub extension_mode: ExtensionMode,
     pub visible: bool,
+    pub deprecated: bool,
 }
 
 impl<'a, Context: ServerContext> ApiEndpoint<Context> {
@@ -80,6 +81,7 @@ impl<'a, Context: ServerContext> ApiEndpoint<Context> {
             tags: vec![],
             extension_mode: func_parameters.extension_mode,
             visible: true,
+            deprecated: false,
         }
     }
 
@@ -102,6 +104,11 @@ impl<'a, Context: ServerContext> ApiEndpoint<Context> {
         self.visible = visible;
         self
     }
+
+    pub fn deprecated(mut self, deprecated: bool) -> Self {
+        self.deprecated = deprecated;
+        self
+    }
 }
 
 /**
@@ -588,6 +595,7 @@ impl<Context: ServerContext> ApiDescription<Context> {
             operation.summary = endpoint.summary.clone();
             operation.description = endpoint.description.clone();
             operation.tags = endpoint.tags.clone();
+            operation.deprecated = endpoint.deprecated;
 
             operation.parameters = endpoint
                 .parameters
diff --git a/dropshot/src/router.rs b/dropshot/src/router.rs
index 06e90200c..936d8d6ad 100644
--- a/dropshot/src/router.rs
+++ b/dropshot/src/router.rs
@@ -824,6 +824,7 @@ mod test {
             tags: vec![],
             extension_mode: Default::default(),
             visible: true,
+            deprecated: false,
         }
     }
 
diff --git a/dropshot_endpoint/src/lib.rs b/dropshot_endpoint/src/lib.rs
index ea49a0cd0..5ee6709fa 100644
--- a/dropshot_endpoint/src/lib.rs
+++ b/dropshot_endpoint/src/lib.rs
@@ -51,8 +51,12 @@ impl MethodType {
 struct EndpointMetadata {
     method: MethodType,
     path: String,
-    tags: Option<Vec<String>>,
-    unpublished: Option<bool>,
+    #[serde(default)]
+    tags: Vec<String>,
+    #[serde(default)]
+    unpublished: bool,
+    #[serde(default)]
+    deprecated: bool,
     content_type: Option<String>,
     _dropshot_crate: Option<String>,
 }
@@ -67,8 +71,12 @@ enum ChannelProtocol {
 struct ChannelMetadata {
     protocol: ChannelProtocol,
     path: String,
-    tags: Option<Vec<String>>,
-    unpublished: Option<bool>,
+    #[serde(default)]
+    tags: Vec<String>,
+    #[serde(default)]
+    unpublished: bool,
+    #[serde(default)]
+    deprecated: bool,
     _dropshot_crate: Option<String>,
 }
 
@@ -94,12 +102,14 @@ const USAGE: &str = "Endpoint handlers must have the following signature:
 ///     method = { DELETE | GET | OPTIONS | PATCH | POST | PUT },
 ///     path = "/path/name/with/{named}/{variables}",
 ///
-///     // Optional tags for the API description
+///     // Optional tags for the operation's description
 ///     tags = [ "all", "your", "OpenAPI", "tags" ],
-///     // A value of `true` causes the API to be omitted from the API description
-///     unpublished = { true | false },
 ///     // Specifies the media type used to encode the request body
 ///     content_type = { "application/json" | "application/x-www-form-urlencoded" }
+///     // A value of `true` marks the operation as deprecated
+///     deprecated = { true | false },
+///     // A value of `true` causes the operation to be omitted from the API description
+///     unpublished = { true | false },
 /// }]
 /// ```
 ///
@@ -153,8 +163,14 @@ fn do_channel(
     attr: proc_macro2::TokenStream,
     item: proc_macro2::TokenStream,
 ) -> Result<(proc_macro2::TokenStream, Vec<Error>), Error> {
-    let ChannelMetadata { protocol, path, tags, unpublished, _dropshot_crate } =
-        from_tokenstream(&attr)?;
+    let ChannelMetadata {
+        protocol,
+        path,
+        tags,
+        unpublished,
+        deprecated,
+        _dropshot_crate,
+    } = from_tokenstream(&attr)?;
     match protocol {
         ChannelProtocol::WEBSOCKETS => {
             // here we construct a wrapper function and mutate the arguments a bit
@@ -225,6 +241,7 @@ fn do_channel(
                 path,
                 tags,
                 unpublished,
+                deprecated,
                 content_type: Some("application/json".to_string()),
                 _dropshot_crate,
             };
@@ -347,18 +364,15 @@ fn do_endpoint_inner(
 
     let tags = metadata
         .tags
-        .map(|v| {
-            v.iter()
-                .map(|tag| {
-                    quote! {
-                        .tag(#tag)
-                    }
-                })
-                .collect::<Vec<_>>()
+        .iter()
+        .map(|tag| {
+            quote! {
+                .tag(#tag)
+            }
         })
-        .unwrap_or_default();
+        .collect::<Vec<_>>();
 
-    let visible = if let Some(true) = metadata.unpublished {
+    let visible = if metadata.unpublished {
         quote! {
             .visible(false)
         }
@@ -366,6 +380,14 @@ fn do_endpoint_inner(
         quote! {}
     };
 
+    let deprecated = if metadata.deprecated {
+        quote! {
+            .deprecated(true)
+        }
+    } else {
+        quote! {}
+    };
+
     let dropshot = get_crate(metadata._dropshot_crate);
 
     let first_arg = match ast.sig.inputs.first() {
@@ -553,6 +575,7 @@ fn do_endpoint_inner(
             #description
             #(#tags)*
             #visible
+            #deprecated
         }
     } else {
         quote! {
@@ -599,7 +622,7 @@ fn do_endpoint_inner(
         errors.insert(0, Error::new_spanned(&ast.sig, USAGE));
     }
 
-    if path.contains(":.*}") && metadata.unpublished != Some(true) {
+    if path.contains(":.*}") && !metadata.unpublished {
         errors.push(Error::new_spanned(
             &attr,
             "paths that contain a wildcard match must include 'unpublished = \


Test patch:
diff --git a/dropshot/tests/test_openapi.json b/dropshot/tests/test_openapi.json
index 98e209d01..7bcc240ab 100644
--- a/dropshot/tests/test_openapi.json
+++ b/dropshot/tests/test_openapi.json
@@ -408,6 +408,36 @@
         }
       }
     },
+    "/test/deprecated": {
+      "get": {
+        "tags": [
+          "it"
+        ],
+        "operationId": "handler24",
+        "responses": {
+          "307": {
+            "description": "redirect (temporary redirect)",
+            "headers": {
+              "location": {
+                "description": "HTTP \"Location\" header",
+                "style": "simple",
+                "required": true,
+                "schema": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "4XX": {
+            "$ref": "#/components/responses/Error"
+          },
+          "5XX": {
+            "$ref": "#/components/responses/Error"
+          }
+        },
+        "deprecated": true
+      }
+    },
     "/test/man/{x}": {
       "delete": {
         "tags": [
diff --git a/dropshot/tests/test_openapi.rs b/dropshot/tests/test_openapi.rs
index 3f7ce94e9..6e581f257 100644
--- a/dropshot/tests/test_openapi.rs
+++ b/dropshot/tests/test_openapi.rs
@@ -455,6 +455,18 @@ async fn handler23(
     Ok(http_response_temporary_redirect(String::from("/path3")).unwrap())
 }
 
+#[endpoint {
+    method = GET,
+    path = "/test/deprecated",
+    tags = [ "it"],
+    deprecated = true,
+}]
+async fn handler24(
+    _rqctx: Arc<RequestContext<()>>,
+) -> Result<HttpResponseTemporaryRedirect, HttpError> {
+    unimplemented!()
+}
+
 fn make_api(
     maybe_tag_config: Option<TagConfig>,
 ) -> Result<ApiDescription<()>, String> {
@@ -487,6 +499,7 @@ fn make_api(
     api.register(handler21)?;
     api.register(handler22)?;
     api.register(handler23)?;
+    api.register(handler24)?;
     Ok(api)
 }
 
diff --git a/dropshot/tests/test_openapi_fuller.json b/dropshot/tests/test_openapi_fuller.json
index b29b35fe9..1db2b1073 100644
--- a/dropshot/tests/test_openapi_fuller.json
+++ b/dropshot/tests/test_openapi_fuller.json
@@ -416,6 +416,36 @@
         }
       }
     },
+    "/test/deprecated": {
+      "get": {
+        "tags": [
+          "it"
+        ],
+        "operationId": "handler24",
+        "responses": {
+          "307": {
+            "description": "redirect (temporary redirect)",
+            "headers": {
+              "location": {
+                "description": "HTTP \"Location\" header",
+                "style": "simple",
+                "required": true,
+                "schema": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "4XX": {
+            "$ref": "#/components/responses/Error"
+          },
+          "5XX": {
+            "$ref": "#/components/responses/Error"
+          }
+        },
+        "deprecated": true
+      }
+    },
     "/test/man/{x}": {
       "delete": {
         "tags": [