Repository: oxidecomputer/dropshot (Rust)

Issue:
top-level types are always inline, so duplicated in the schema
## The problem

In oxidecomputer/omicron#64 @david-crespo reported that when running openapi-generator for TypeScript on the Omicron API schema, he was getting duplicate type definitions like `ApiRackView` and `ApiRackView1`.  The types were exactly the same.  This API has two different endpoints that use this type: one returns an `ApiRackView`, and the other returns a list of `ApiRackView`.  It appears that the dropshot-generated OpenAPI spec inlines the top-level type in the response, but it generates references for subtypes.

## Test case

I was able to replicate this by modifying test_openapi.rs with this change:

```diff
diff --git a/dropshot/tests/test_openapi.rs b/dropshot/tests/test_openapi.rs
index 97daf80..3af7e5f 100644
--- a/dropshot/tests/test_openapi.rs
+++ b/dropshot/tests/test_openapi.rs
@@ -136,6 +136,36 @@ async fn handler7(
     unimplemented!();
 }
 
+#[derive(JsonSchema, Serialize)]
+struct NeverDuplicatedTopLevel {
+    b: NeverDuplicatedNextLevel,
+}
+
+#[derive(JsonSchema, Serialize)]
+struct NeverDuplicatedNextLevel {
+    v: bool,
+}
+
+#[endpoint {
+    method = PUT,
+    path = "/dup1",
+}]
+async fn handler8(
+    _rqctx: Arc<RequestContext<()>>,
+) -> Result<HttpResponseOk<NeverDuplicatedTopLevel>, HttpError> {
+    unimplemented!();
+}
+
+#[endpoint {
+    method = PUT,
+    path = "/dup2",
+}]
+async fn handler9(
+    _rqctx: Arc<RequestContext<()>>,
+) -> Result<HttpResponseOk<NeverDuplicatedTopLevel>, HttpError> {
+    unimplemented!();
+}
+
 fn make_api() -> Result<ApiDescription<()>, String> {
     let mut api = ApiDescription::new();
     api.register(handler1)?;
@@ -145,6 +175,8 @@ fn make_api() -> Result<ApiDescription<()>, String> {
     api.register(handler5)?;
     api.register(handler6)?;
     api.register(handler7)?;
+    api.register(handler8)?;
+    api.register(handler9)?;
     Ok(api)
 }

```

Here's how that changes the generated schema for that test:

```diff
dap@zathras dropshot $ git diff dropshot/tests/test_openapi.json
diff --git a/dropshot/tests/test_openapi.json b/dropshot/tests/test_openapi.json
index 1233f79..688000d 100644
--- a/dropshot/tests/test_openapi.json
+++ b/dropshot/tests/test_openapi.json
@@ -26,6 +26,58 @@
         }
       }
     },
+    "/dup1": {
+      "put": {
+        "operationId": "handler8",
+        "responses": {
+          "200": {
+            "description": "successful operation",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "title": "NeverDuplicatedTopLevel",
+                  "type": "object",
+                  "properties": {
+                    "b": {
+                      "$ref": "#/components/schemas/NeverDuplicatedNextLevel"
+                    }
+                  },
+                  "required": [
+                    "b"
+                  ]
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/dup2": {
+      "put": {
+        "operationId": "handler9",
+        "responses": {
+          "200": {
+            "description": "successful operation",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "title": "NeverDuplicatedTopLevel",
+                  "type": "object",
+                  "properties": {
+                    "b": {
+                      "$ref": "#/components/schemas/NeverDuplicatedNextLevel"
+                    }
+                  },
+                  "required": [
+                    "b"
+                  ]
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/impairment": {
       "get": {
         "operationId": "handler6",
@@ -288,6 +340,17 @@
   },
   "components": {
     "schemas": {
+      "NeverDuplicatedNextLevel": {
+        "type": "object",
+        "properties": {
+          "v": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "v"
+        ]
+      },
       "ResponseItem": {
         "type": "object",
         "properties": {
```

We see that the top-level type shows up twice, inline in both places, while the next level type gets a reference instead.

## Analysis

When you use the `endpoint` macro, we generate a `From<...> for ApiEndpoint` for this specific handler

```rust
#[allow(non_camel_case_types, missing_docs)]
#[doc = "API Endpoint: handler9"]
struct handler9 {}
#[allow(non_upper_case_globals, missing_docs)]
#[doc = "API Endpoint: handler9"]
const handler9: handler9 = handler9 {};
impl From<handler9>
    for dropshot::ApiEndpoint<
        <Arc<RequestContext<()>> as dropshot::RequestContextArgument>::Context,
    >
{
    fn from(_: handler9) -> Self {
        async fn handler9(
            _rqctx: Arc<RequestContext<()>>,
        ) -> Result<HttpResponseOk<NeverDuplicatedTopLevel>, HttpError> {
            ::core::panicking::panic("not implemented");
        }
        dropshot::ApiEndpoint::new(
            "handler9".to_string(),
            handler9,
            dropshot::Method::PUT,
            "/dup2",
        )
    }
}
```

(That's from `cargo expand -p dropshot --test=test_openapi` with my changes.)

When you register the handler function, we use that `From` impl (well, the corresponding `Into`) here:
https://github.com/oxidecomputer/dropshot/blob/e04241c9eb6a19bd9b0b42718adad8b6d7bab29c/dropshot/src/api_description.rs#L222-L226

We see above that that invokes `ApiEndpoint::new()`, which winds up invoking `metadata()` on the response type:
https://github.com/oxidecomputer/dropshot/blob/e04241c9eb6a19bd9b0b42718adad8b6d7bab29c/dropshot/src/api_description.rs#L60

`ResponseType` is `HttpResponseOk` here, and the `metadata()` impl comes from `HttpTypedResponse`:
https://github.com/oxidecomputer/dropshot/blob/e04241c9eb6a19bd9b0b42718adad8b6d7bab29c/dropshot/src/handler.rs#L1140-L1149

What's important here is that we supply `Body::json_schema` as the `schema` function here:
https://github.com/oxidecomputer/dropshot/blob/e04241c9eb6a19bd9b0b42718adad8b6d7bab29c/dropshot/src/handler.rs#L1144

Again, that's all at registration time.  Later when we generate the schema, we use that `schema` function to generate a schema for the response body:
https://github.com/oxidecomputer/dropshot/blob/e04241c9eb6a19bd9b0b42718adad8b6d7bab29c/dropshot/src/api_description.rs#L524-L527

Let's take a closer look at the derived `JsonSchema` impls.  For the top-level type, it looks like this (back to `cargo expand`):

```rust
impl schemars::JsonSchema for NeverDuplicatedTopLevel {
    fn schema_name() -> std::string::String {
        "NeverDuplicatedTopLevel".to_owned()
    }
    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
        {
            let schema = {
                let mut schema_object = schemars::schema::SchemaObject {
                    instance_type: Some(schemars::schema::InstanceType::Object.into()),
                    ..Default::default()
                };
                <NeverDuplicatedNextLevel as schemars::JsonSchema>::add_schema_as_property(
                    gen,
                    &mut schema_object,
                    "b".to_owned(),
                    None,
                    true,
                );
                schemars::schema::Schema::Object(schema_object)
            };
            gen.apply_metadata(schema, None)
        }
    }
}
```

The schema for `NeverDuplicatedNextLevel` must be added via `add_schema_as_property`, which winds up invoking not `json_schema()` on `NeverDuplicatedNextLevel`, but [gen.subschema_for()](https://docs.rs/schemars/0.8.3/schemars/gen/struct.SchemaGenerator.html#method.subschema_for).  That's documented as:

> Generates a JSON Schema for the type T, and returns either the schema itself or a $ref schema referencing T’s schema.
> 
> If T is referenceable, this will add T’s schema to this generator’s definitions, and return a $ref schema referencing that schema. Otherwise, this method behaves identically to JsonSchema::json_schema.

This explains why the non-top-level schemas get references: JsonSchema internally uses a function that tries to add references if possible.  The top-level ones don't get references because we're using `json_schema()` directly, which always generates a new, non-reference schema.


Discussion:
With this change:

```diff
diff --git a/dropshot/src/handler.rs b/dropshot/src/handler.rs
index 85d544d..38560c2 100644
--- a/dropshot/src/handler.rs
+++ b/dropshot/src/handler.rs
@@ -1163,13 +1163,20 @@ where
         ApiEndpointResponse {
             schema: Some(ApiSchemaGenerator::Gen {
                 name: T::Body::schema_name,
-                schema: T::Body::json_schema,
+                schema: make_subschema_for::<T::Body>,
             }),
             success: Some(T::STATUS_CODE),
             description: Some(T::DESCRIPTION.to_string()),
         }
     }
 }
+
+fn make_subschema_for<T: JsonSchema>(
+    gen: &mut schemars::gen::SchemaGenerator,
+) -> schemars::schema::Schema {
+    gen.subschema_for::<T>()
+}
+
```

With this change, this is how the schema for test_openapi.rs changes:

```diff
dap@zathras dropshot $ git diff dropshot/tests/test_openapi.json
diff --git a/dropshot/tests/test_openapi.json b/dropshot/tests/test_openapi.json
index 1233f79..4ce534a 100644
--- a/dropshot/tests/test_openapi.json
+++ b/dropshot/tests/test_openapi.json
@@ -26,6 +26,40 @@
         }
       }
     },
+    "/dup1": {
+      "put": {
+        "operationId": "handler8",
+        "responses": {
+          "200": {
+            "description": "successful operation",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/NeverDuplicatedTopLevel"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/dup2": {
+      "put": {
+        "operationId": "handler9",
+        "responses": {
+          "200": {
+            "description": "successful operation",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/NeverDuplicatedTopLevel"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/impairment": {
       "get": {
         "operationId": "handler6",
@@ -67,25 +101,7 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "title": "ResponseItemResultsPage",
-                  "description": "A single page of results",
-                  "type": "object",
-                  "properties": {
-                    "items": {
-                      "description": "list of items on this page of results",
-                      "type": "array",
-                      "items": {
-                        "$ref": "#/components/schemas/ResponseItem"
-                      }
-                    },
-                    "next_page": {
-                      "description": "token used to fetch the next page of results (if any)",
-                      "type": "string"
-                    }
-                  },
-                  "required": [
-                    "items"
-                  ]
+                  "$ref": "#/components/schemas/ResponseItemResultsPage"
                 }
               }
             }
@@ -122,8 +138,7 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "title": "Response",
-                  "type": "object"
+                  "$ref": "#/components/schemas/Response"
                 }
               }
             }
@@ -288,6 +303,31 @@
   },
   "components": {
     "schemas": {
+      "NeverDuplicatedNextLevel": {
+        "type": "object",
+        "properties": {
+          "v": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "v"
+        ]
+      },
+      "NeverDuplicatedTopLevel": {
+        "type": "object",
+        "properties": {
+          "b": {
+            "$ref": "#/components/schemas/NeverDuplicatedNextLevel"
+          }
+        },
+        "required": [
+          "b"
+        ]
+      },
+      "Response": {
+        "type": "object"
+      },
       "ResponseItem": {
         "type": "object",
         "properties": {
@@ -298,6 +338,26 @@
         "required": [
           "word"
         ]
+      },
+      "ResponseItemResultsPage": {
+        "description": "A single page of results",
+        "type": "object",
+        "properties": {
+          "items": {
+            "description": "list of items on this page of results",
+            "type": "array",
+            "items": {
+              "$ref": "#/components/schemas/ResponseItem"
+            }
+          },
+          "next_page": {
+            "description": "token used to fetch the next page of results (if any)",
+            "type": "string"
+          }
+        },
+        "required": [
+          "items"
+        ]
       }
     }
   }
```

The hunks related to ResponseItemResultsPage are noise -- those are other instances of the same problem being fixed.  Importantly, we see the top-level type showing up as a reference now, with the body showing up in `components.schemas` in the spec.

This is promising but I'm not positive I've really applied the fix at the right spot.  That said, I'm not sure where else it would go.

There are also a few other callers of `json_schema()` that may need the same treatment.  The only suspicious one I found is `get_metadata()` in dropshot/src/handler.rs, which might be causing us to duplicating types that are used as parameters.  I'm not sure if the fix will just work there because the generator is instantiated right there.
Query parameters seem to have the same problem.  I confirmed with this change:

```diff
diff --git a/dropshot/tests/test_openapi.rs b/dropshot/tests/test_openapi.rs
index 3af7e5f..621ce4f 100644
--- a/dropshot/tests/test_openapi.rs
+++ b/dropshot/tests/test_openapi.rs
@@ -147,7 +147,7 @@ struct NeverDuplicatedNextLevel {
 }
 
 #[endpoint {
-    method = PUT,
+    method = GET,
     path = "/dup1",
 }]
 async fn handler8(
@@ -157,7 +157,7 @@ async fn handler8(
 }
 
 #[endpoint {
-    method = PUT,
+    method = GET,
     path = "/dup2",
 }]
 async fn handler9(
@@ -166,6 +166,38 @@ async fn handler9(
     unimplemented!();
 }
 
+#[derive(Deserialize, JsonSchema)]
+struct NeverDuplicatedParamTopLevel {
+    _b: NeverDuplicatedParamNextLevel
+}
+
+#[derive(Deserialize, JsonSchema)]
+struct NeverDuplicatedParamNextLevel {
+    _v: bool
+}
+
+#[endpoint {
+    method = PUT,
+    path = "/dup3",
+}]
+async fn handler10(
+    _rqctx: Arc<RequestContext<()>>,
+    _q: Query<NeverDuplicatedParamTopLevel>,
+) -> Result<HttpResponseOk<()>, HttpError> {
+    unimplemented!();
+}
+
+#[endpoint {
+    method = PUT,
+    path = "/dup4",
+}]
+async fn handler11(
+    _rqctx: Arc<RequestContext<()>>,
+    _q: Query<NeverDuplicatedParamTopLevel>,
+) -> Result<HttpResponseOk<()>, HttpError> {
+    unimplemented!();
+}
+
 fn make_api() -> Result<ApiDescription<()>, String> {
     let mut api = ApiDescription::new();
     api.register(handler1)?;
@@ -177,6 +209,8 @@ fn make_api() -> Result<ApiDescription<()>, String> {
     api.register(handler7)?;
     api.register(handler8)?;
     api.register(handler9)?;
+    api.register(handler10)?;
+    api.register(handler11)?;
     Ok(api)
 }

```

We get this change in spec:

```diff
diff --git a/dropshot/tests/test_openapi_fuller.json b/dropshot/tests/test_openapi_fuller.json
index 19a1c59..e602b4a 100644
--- a/dropshot/tests/test_openapi_fuller.json
+++ b/dropshot/tests/test_openapi_fuller.json
@@ -35,7 +35,7 @@
       }
     },
     "/dup1": {
-      "put": {
+      "get": {
         "operationId": "handler8",
         "responses": {
           "200": {
@@ -52,7 +52,7 @@
       }
     },
     "/dup2": {
-      "put": {
+      "get": {
         "operationId": "handler9",
         "responses": {
           "200": {
@@ -68,6 +68,48 @@
         }
       }
     },
+    "/dup3": {
+      "put": {
+        "operationId": "handler10",
+        "parameters": [
+          {
+            "in": "query",
+            "name": "_b",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/NeverDuplicatedParamNextLevel"
+            },
+            "style": "form"
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "successful operation"
+          }
+        }
+      }
+    },
+    "/dup4": {
+      "put": {
+        "operationId": "handler11",
+        "parameters": [
+          {
+            "in": "query",
+            "name": "_b",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/NeverDuplicatedParamNextLevel"
+            },
+            "style": "form"
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "successful operation"
+          }
+        }
+      }
+    },
     "/impairment": {
       "get": {
         "operationId": "handler6",
@@ -366,6 +408,17 @@
         "required": [
           "items"
         ]
+      },
+      "NeverDuplicatedParamNextLevel": {
+        "type": "object",
+        "properties": {
+          "_v": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "_v"
+        ]
       }
     }
   }
```
I think this probably also affects TypedBody as well.

The query params case is a little trickier to fix than I expected because the obvious change breaks the way we're detecting whether this is a paginated endpoint.
I think I misread the output from the query params case in [my comment above](https://github.com/oxidecomputer/dropshot/issues/104#issuecomment-821477057).  I realized this when I took a closer look at the generated schema.  The spec sort of looks like it has inlined the top-level type, in that there are two schemas for an object with property `_b`, etc.  However, being query parameters, there's not an actual type for _all_ the parameters.  The `NeverDuplicatedParamTopLevel` type doesn't appear _anywhere_ in the spec, let alone twice.  The inner type is already a reference because it's not a top-level type.  So the query params case was correct before my change.



Patch:
diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc
index 19598c3fa..659b87d03 100644
--- a/CHANGELOG.adoc
+++ b/CHANGELOG.adoc
@@ -15,6 +15,7 @@
 
 https://github.com/oxidecomputer/dropshot/compare/v0.5.1\...HEAD[Full list of commits]
 
+* https://github.com/oxidecomputer/dropshot/pull/105[#105] When generating an OpenAPI spec, Dropshot now uses references rather than inline schemas to represent request and response bodies.
 * https://github.com/oxidecomputer/dropshot/pull/103[#103] When the Dropshot server is dropped before having been shut down, Dropshot now attempts to gracefully shut down rather than panic.
 
 === Breaking Changes
diff --git a/dropshot/src/handler.rs b/dropshot/src/handler.rs
index cf3d2570e..3d01e382b 100644
--- a/dropshot/src/handler.rs
+++ b/dropshot/src/handler.rs
@@ -960,7 +960,7 @@ where
             true,
             ApiSchemaGenerator::Gen {
                 name: BodyType::schema_name,
-                schema: BodyType::json_schema,
+                schema: make_subschema_for::<BodyType>,
             },
             vec![],
         );
@@ -1141,13 +1141,20 @@ where
         ApiEndpointResponse {
             schema: Some(ApiSchemaGenerator::Gen {
                 name: T::Body::schema_name,
-                schema: T::Body::json_schema,
+                schema: make_subschema_for::<T::Body>,
             }),
             success: Some(T::STATUS_CODE),
             description: Some(T::DESCRIPTION.to_string()),
         }
     }
 }
+
+fn make_subschema_for<T: JsonSchema>(
+    gen: &mut schemars::gen::SchemaGenerator,
+) -> schemars::schema::Schema {
+    gen.subschema_for::<T>()
+}
+
 /**
  * `HttpResponseCreated<T: Serialize>` wraps an object of any serializable type.
  * It denotes an HTTP 201 "Created" response whose body is generated by


Test patch:
diff --git a/dropshot/tests/test_openapi.json b/dropshot/tests/test_openapi.json
index 7b678e877..e2c40352d 100644
--- a/dropshot/tests/test_openapi.json
+++ b/dropshot/tests/test_openapi.json
@@ -26,6 +26,159 @@
         }
       }
     },
+    "/dup1": {
+      "get": {
+        "operationId": "handler8",
+        "responses": {
+          "200": {
+            "description": "successful operation",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/NeverDuplicatedResponseTopLevel"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/dup2": {
+      "get": {
+        "operationId": "handler9",
+        "responses": {
+          "200": {
+            "description": "successful operation",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/NeverDuplicatedResponseTopLevel"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/dup3": {
+      "put": {
+        "operationId": "handler10",
+        "parameters": [
+          {
+            "in": "query",
+            "name": "_b",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/NeverDuplicatedParamNextLevel"
+            },
+            "style": "form"
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "successful operation"
+          }
+        }
+      }
+    },
+    "/dup4": {
+      "put": {
+        "operationId": "handler11",
+        "parameters": [
+          {
+            "in": "query",
+            "name": "_b",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/NeverDuplicatedParamNextLevel"
+            },
+            "style": "form"
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "successful operation"
+          }
+        }
+      }
+    },
+    "/dup5": {
+      "put": {
+        "operationId": "handler12",
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/NeverDuplicatedBodyTopLevel"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "successful operation"
+          }
+        }
+      }
+    },
+    "/dup6": {
+      "put": {
+        "operationId": "handler13",
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/NeverDuplicatedBodyTopLevel"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "successful operation"
+          }
+        }
+      }
+    },
+    "/dup7": {
+      "put": {
+        "operationId": "handler14",
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/NeverDuplicatedTop"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "successful operation"
+          }
+        }
+      }
+    },
+    "/dup8": {
+      "get": {
+        "operationId": "handler15",
+        "responses": {
+          "200": {
+            "description": "successful operation",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/NeverDuplicatedTop"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/impairment": {
       "get": {
         "operationId": "handler6",
@@ -67,25 +220,7 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "title": "ResponseItemResultsPage",
-                  "description": "A single page of results",
-                  "type": "object",
-                  "properties": {
-                    "items": {
-                      "description": "list of items on this page of results",
-                      "type": "array",
-                      "items": {
-                        "$ref": "#/components/schemas/ResponseItem"
-                      }
-                    },
-                    "next_page": {
-                      "description": "token used to fetch the next page of results (if any)",
-                      "type": "string"
-                    }
-                  },
-                  "required": [
-                    "items"
-                  ]
+                  "$ref": "#/components/schemas/ResponseItemResultsPage"
                 }
               }
             }
@@ -101,16 +236,7 @@
           "content": {
             "application/json": {
               "schema": {
-                "title": "BodyParam",
-                "type": "object",
-                "properties": {
-                  "_x": {
-                    "type": "string"
-                  }
-                },
-                "required": [
-                  "_x"
-                ]
+                "$ref": "#/components/schemas/BodyParam"
               }
             }
           },
@@ -122,8 +248,7 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "title": "Response",
-                  "type": "object"
+                  "$ref": "#/components/schemas/Response"
                 }
               }
             }
@@ -219,16 +344,7 @@
           "content": {
             "application/json": {
               "schema": {
-                "title": "BodyParam",
-                "type": "object",
-                "properties": {
-                  "_x": {
-                    "type": "string"
-                  }
-                },
-                "required": [
-                  "_x"
-                ]
+                "$ref": "#/components/schemas/BodyParam"
               }
             }
           },
@@ -288,6 +404,86 @@
   },
   "components": {
     "schemas": {
+      "BodyParam": {
+        "type": "object",
+        "properties": {
+          "_x": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "_x"
+        ]
+      },
+      "NeverDuplicatedBodyNextLevel": {
+        "type": "object",
+        "properties": {
+          "_v": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "_v"
+        ]
+      },
+      "NeverDuplicatedBodyTopLevel": {
+        "type": "object",
+        "properties": {
+          "_b": {
+            "$ref": "#/components/schemas/NeverDuplicatedBodyNextLevel"
+          }
+        },
+        "required": [
+          "_b"
+        ]
+      },
+      "NeverDuplicatedNext": {
+        "type": "object",
+        "properties": {
+          "_v": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "_v"
+        ]
+      },
+      "NeverDuplicatedResponseNextLevel": {
+        "type": "object",
+        "properties": {
+          "v": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "v"
+        ]
+      },
+      "NeverDuplicatedResponseTopLevel": {
+        "type": "object",
+        "properties": {
+          "b": {
+            "$ref": "#/components/schemas/NeverDuplicatedResponseNextLevel"
+          }
+        },
+        "required": [
+          "b"
+        ]
+      },
+      "NeverDuplicatedTop": {
+        "type": "object",
+        "properties": {
+          "_b": {
+            "$ref": "#/components/schemas/NeverDuplicatedNext"
+          }
+        },
+        "required": [
+          "_b"
+        ]
+      },
+      "Response": {
+        "type": "object"
+      },
       "ResponseItem": {
         "type": "object",
         "properties": {
@@ -298,6 +494,37 @@
         "required": [
           "word"
         ]
+      },
+      "ResponseItemResultsPage": {
+        "description": "A single page of results",
+        "type": "object",
+        "properties": {
+          "items": {
+            "description": "list of items on this page of results",
+            "type": "array",
+            "items": {
+              "$ref": "#/components/schemas/ResponseItem"
+            }
+          },
+          "next_page": {
+            "description": "token used to fetch the next page of results (if any)",
+            "type": "string"
+          }
+        },
+        "required": [
+          "items"
+        ]
+      },
+      "NeverDuplicatedParamNextLevel": {
+        "type": "object",
+        "properties": {
+          "_v": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "_v"
+        ]
       }
     }
   }
diff --git a/dropshot/tests/test_openapi.rs b/dropshot/tests/test_openapi.rs
index 97daf804c..3c3b0076f 100644
--- a/dropshot/tests/test_openapi.rs
+++ b/dropshot/tests/test_openapi.rs
@@ -136,6 +136,152 @@ async fn handler7(
     unimplemented!();
 }
 
+/*
+ * Test that we do not generate duplicate type definitions when the same type is
+ * returned by two different handler functions.
+ */
+
+#[derive(JsonSchema, Serialize)]
+struct NeverDuplicatedResponseTopLevel {
+    b: NeverDuplicatedResponseNextLevel,
+}
+
+#[derive(JsonSchema, Serialize)]
+struct NeverDuplicatedResponseNextLevel {
+    v: bool,
+}
+
+#[endpoint {
+    method = GET,
+    path = "/dup1",
+}]
+async fn handler8(
+    _rqctx: Arc<RequestContext<()>>,
+) -> Result<HttpResponseOk<NeverDuplicatedResponseTopLevel>, HttpError> {
+    unimplemented!();
+}
+
+#[endpoint {
+    method = GET,
+    path = "/dup2",
+}]
+async fn handler9(
+    _rqctx: Arc<RequestContext<()>>,
+) -> Result<HttpResponseOk<NeverDuplicatedResponseTopLevel>, HttpError> {
+    unimplemented!();
+}
+
+/*
+ * Similarly, test that we do not generate duplicate type definitions when the
+ * same type is accepted as a query parameter to two different handler
+ * functions.
+ */
+
+#[derive(Deserialize, JsonSchema)]
+struct NeverDuplicatedParamTopLevel {
+    _b: NeverDuplicatedParamNextLevel,
+}
+
+#[derive(Deserialize, JsonSchema)]
+struct NeverDuplicatedParamNextLevel {
+    _v: bool,
+}
+
+#[endpoint {
+    method = PUT,
+    path = "/dup3",
+}]
+async fn handler10(
+    _rqctx: Arc<RequestContext<()>>,
+    _q: Query<NeverDuplicatedParamTopLevel>,
+) -> Result<HttpResponseOk<()>, HttpError> {
+    unimplemented!();
+}
+
+#[endpoint {
+    method = PUT,
+    path = "/dup4",
+}]
+async fn handler11(
+    _rqctx: Arc<RequestContext<()>>,
+    _q: Query<NeverDuplicatedParamTopLevel>,
+) -> Result<HttpResponseOk<()>, HttpError> {
+    unimplemented!();
+}
+
+/*
+ * Similarly, test that we do not generate duplicate type definitions when the
+ * same type is accepted as a typed body to two different handler functions.
+ */
+
+#[derive(Deserialize, JsonSchema)]
+struct NeverDuplicatedBodyTopLevel {
+    _b: NeverDuplicatedBodyNextLevel,
+}
+
+#[derive(Deserialize, JsonSchema)]
+struct NeverDuplicatedBodyNextLevel {
+    _v: bool,
+}
+
+#[endpoint {
+    method = PUT,
+    path = "/dup5",
+}]
+async fn handler12(
+    _rqctx: Arc<RequestContext<()>>,
+    _b: TypedBody<NeverDuplicatedBodyTopLevel>,
+) -> Result<HttpResponseOk<()>, HttpError> {
+    unimplemented!();
+}
+
+#[endpoint {
+    method = PUT,
+    path = "/dup6",
+}]
+async fn handler13(
+    _rqctx: Arc<RequestContext<()>>,
+    _b: TypedBody<NeverDuplicatedBodyTopLevel>,
+) -> Result<HttpResponseOk<()>, HttpError> {
+    unimplemented!();
+}
+
+/*
+ * Finally, test that we do not generate duplicate type definitions when the
+ * same type is used in two different places.
+ */
+
+#[derive(Deserialize, JsonSchema, Serialize)]
+struct NeverDuplicatedTop {
+    _b: NeverDuplicatedNext,
+}
+
+#[derive(Deserialize, JsonSchema, Serialize)]
+struct NeverDuplicatedNext {
+    _v: bool,
+}
+
+#[endpoint {
+    method = PUT,
+    path = "/dup7",
+}]
+async fn handler14(
+    _rqctx: Arc<RequestContext<()>>,
+    _b: TypedBody<NeverDuplicatedTop>,
+) -> Result<HttpResponseOk<()>, HttpError> {
+    unimplemented!();
+}
+
+#[endpoint {
+    method = GET,
+    path = "/dup8",
+}]
+async fn handler15(
+    _rqctx: Arc<RequestContext<()>>,
+) -> Result<HttpResponseOk<NeverDuplicatedTop>, HttpError> {
+    unimplemented!();
+}
+
 fn make_api() -> Result<ApiDescription<()>, String> {
     let mut api = ApiDescription::new();
     api.register(handler1)?;
@@ -145,6 +291,14 @@ fn make_api() -> Result<ApiDescription<()>, String> {
     api.register(handler5)?;
     api.register(handler6)?;
     api.register(handler7)?;
+    api.register(handler8)?;
+    api.register(handler9)?;
+    api.register(handler10)?;
+    api.register(handler11)?;
+    api.register(handler12)?;
+    api.register(handler13)?;
+    api.register(handler14)?;
+    api.register(handler15)?;
     Ok(api)
 }
 
diff --git a/dropshot/tests/test_openapi_fuller.json b/dropshot/tests/test_openapi_fuller.json
index 2927b2095..b3a9da025 100644
--- a/dropshot/tests/test_openapi_fuller.json
+++ b/dropshot/tests/test_openapi_fuller.json
@@ -34,6 +34,159 @@
         }
       }
     },
+    "/dup1": {
+      "get": {
+        "operationId": "handler8",
+        "responses": {
+          "200": {
+            "description": "successful operation",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/NeverDuplicatedResponseTopLevel"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/dup2": {
+      "get": {
+        "operationId": "handler9",
+        "responses": {
+          "200": {
+            "description": "successful operation",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/NeverDuplicatedResponseTopLevel"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/dup3": {
+      "put": {
+        "operationId": "handler10",
+        "parameters": [
+          {
+            "in": "query",
+            "name": "_b",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/NeverDuplicatedParamNextLevel"
+            },
+            "style": "form"
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "successful operation"
+          }
+        }
+      }
+    },
+    "/dup4": {
+      "put": {
+        "operationId": "handler11",
+        "parameters": [
+          {
+            "in": "query",
+            "name": "_b",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/NeverDuplicatedParamNextLevel"
+            },
+            "style": "form"
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "successful operation"
+          }
+        }
+      }
+    },
+    "/dup5": {
+      "put": {
+        "operationId": "handler12",
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/NeverDuplicatedBodyTopLevel"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "successful operation"
+          }
+        }
+      }
+    },
+    "/dup6": {
+      "put": {
+        "operationId": "handler13",
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/NeverDuplicatedBodyTopLevel"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "successful operation"
+          }
+        }
+      }
+    },
+    "/dup7": {
+      "put": {
+        "operationId": "handler14",
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/NeverDuplicatedTop"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "successful operation"
+          }
+        }
+      }
+    },
+    "/dup8": {
+      "get": {
+        "operationId": "handler15",
+        "responses": {
+          "200": {
+            "description": "successful operation",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/NeverDuplicatedTop"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/impairment": {
       "get": {
         "operationId": "handler6",
@@ -75,25 +228,7 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "title": "ResponseItemResultsPage",
-                  "description": "A single page of results",
-                  "type": "object",
-                  "properties": {
-                    "items": {
-                      "description": "list of items on this page of results",
-                      "type": "array",
-                      "items": {
-                        "$ref": "#/components/schemas/ResponseItem"
-                      }
-                    },
-                    "next_page": {
-                      "description": "token used to fetch the next page of results (if any)",
-                      "type": "string"
-                    }
-                  },
-                  "required": [
-                    "items"
-                  ]
+                  "$ref": "#/components/schemas/ResponseItemResultsPage"
                 }
               }
             }
@@ -109,16 +244,7 @@
           "content": {
             "application/json": {
               "schema": {
-                "title": "BodyParam",
-                "type": "object",
-                "properties": {
-                  "_x": {
-                    "type": "string"
-                  }
-                },
-                "required": [
-                  "_x"
-                ]
+                "$ref": "#/components/schemas/BodyParam"
               }
             }
           },
@@ -130,8 +256,7 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "title": "Response",
-                  "type": "object"
+                  "$ref": "#/components/schemas/Response"
                 }
               }
             }
@@ -227,16 +352,7 @@
           "content": {
             "application/json": {
               "schema": {
-                "title": "BodyParam",
-                "type": "object",
-                "properties": {
-                  "_x": {
-                    "type": "string"
-                  }
-                },
-                "required": [
-                  "_x"
-                ]
+                "$ref": "#/components/schemas/BodyParam"
               }
             }
           },
@@ -296,6 +412,86 @@
   },
   "components": {
     "schemas": {
+      "BodyParam": {
+        "type": "object",
+        "properties": {
+          "_x": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "_x"
+        ]
+      },
+      "NeverDuplicatedBodyNextLevel": {
+        "type": "object",
+        "properties": {
+          "_v": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "_v"
+        ]
+      },
+      "NeverDuplicatedBodyTopLevel": {
+        "type": "object",
+        "properties": {
+          "_b": {
+            "$ref": "#/components/schemas/NeverDuplicatedBodyNextLevel"
+          }
+        },
+        "required": [
+          "_b"
+        ]
+      },
+      "NeverDuplicatedNext": {
+        "type": "object",
+        "properties": {
+          "_v": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "_v"
+        ]
+      },
+      "NeverDuplicatedResponseNextLevel": {
+        "type": "object",
+        "properties": {
+          "v": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "v"
+        ]
+      },
+      "NeverDuplicatedResponseTopLevel": {
+        "type": "object",
+        "properties": {
+          "b": {
+            "$ref": "#/components/schemas/NeverDuplicatedResponseNextLevel"
+          }
+        },
+        "required": [
+          "b"
+        ]
+      },
+      "NeverDuplicatedTop": {
+        "type": "object",
+        "properties": {
+          "_b": {
+            "$ref": "#/components/schemas/NeverDuplicatedNext"
+          }
+        },
+        "required": [
+          "_b"
+        ]
+      },
+      "Response": {
+        "type": "object"
+      },
       "ResponseItem": {
         "type": "object",
         "properties": {
@@ -306,6 +502,37 @@
         "required": [
           "word"
         ]
+      },
+      "ResponseItemResultsPage": {
+        "description": "A single page of results",
+        "type": "object",
+        "properties": {
+          "items": {
+            "description": "list of items on this page of results",
+            "type": "array",
+            "items": {
+              "$ref": "#/components/schemas/ResponseItem"
+            }
+          },
+          "next_page": {
+            "description": "token used to fetch the next page of results (if any)",
+            "type": "string"
+          }
+        },
+        "required": [
+          "items"
+        ]
+      },
+      "NeverDuplicatedParamNextLevel": {
+        "type": "object",
+        "properties": {
+          "_v": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "_v"
+        ]
       }
     }
   }
diff --git a/dropshot/tests/test_openapi_old.json b/dropshot/tests/test_openapi_old.json
index 7b678e877..e2c40352d 100644
--- a/dropshot/tests/test_openapi_old.json
+++ b/dropshot/tests/test_openapi_old.json
@@ -26,6 +26,159 @@
         }
       }
     },
+    "/dup1": {
+      "get": {
+        "operationId": "handler8",
+        "responses": {
+          "200": {
+            "description": "successful operation",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/NeverDuplicatedResponseTopLevel"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/dup2": {
+      "get": {
+        "operationId": "handler9",
+        "responses": {
+          "200": {
+            "description": "successful operation",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/NeverDuplicatedResponseTopLevel"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/dup3": {
+      "put": {
+        "operationId": "handler10",
+        "parameters": [
+          {
+            "in": "query",
+            "name": "_b",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/NeverDuplicatedParamNextLevel"
+            },
+            "style": "form"
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "successful operation"
+          }
+        }
+      }
+    },
+    "/dup4": {
+      "put": {
+        "operationId": "handler11",
+        "parameters": [
+          {
+            "in": "query",
+            "name": "_b",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/NeverDuplicatedParamNextLevel"
+            },
+            "style": "form"
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "successful operation"
+          }
+        }
+      }
+    },
+    "/dup5": {
+      "put": {
+        "operationId": "handler12",
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/NeverDuplicatedBodyTopLevel"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "successful operation"
+          }
+        }
+      }
+    },
+    "/dup6": {
+      "put": {
+        "operationId": "handler13",
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/NeverDuplicatedBodyTopLevel"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "successful operation"
+          }
+        }
+      }
+    },
+    "/dup7": {
+      "put": {
+        "operationId": "handler14",
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/NeverDuplicatedTop"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "successful operation"
+          }
+        }
+      }
+    },
+    "/dup8": {
+      "get": {
+        "operationId": "handler15",
+        "responses": {
+          "200": {
+            "description": "successful operation",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/NeverDuplicatedTop"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/impairment": {
       "get": {
         "operationId": "handler6",
@@ -67,25 +220,7 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "title": "ResponseItemResultsPage",
-                  "description": "A single page of results",
-                  "type": "object",
-                  "properties": {
-                    "items": {
-                      "description": "list of items on this page of results",
-                      "type": "array",
-                      "items": {
-                        "$ref": "#/components/schemas/ResponseItem"
-                      }
-                    },
-                    "next_page": {
-                      "description": "token used to fetch the next page of results (if any)",
-                      "type": "string"
-                    }
-                  },
-                  "required": [
-                    "items"
-                  ]
+                  "$ref": "#/components/schemas/ResponseItemResultsPage"
                 }
               }
             }
@@ -101,16 +236,7 @@
           "content": {
             "application/json": {
               "schema": {
-                "title": "BodyParam",
-                "type": "object",
-                "properties": {
-                  "_x": {
-                    "type": "string"
-                  }
-                },
-                "required": [
-                  "_x"
-                ]
+                "$ref": "#/components/schemas/BodyParam"
               }
             }
           },
@@ -122,8 +248,7 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "title": "Response",
-                  "type": "object"
+                  "$ref": "#/components/schemas/Response"
                 }
               }
             }
@@ -219,16 +344,7 @@
           "content": {
             "application/json": {
               "schema": {
-                "title": "BodyParam",
-                "type": "object",
-                "properties": {
-                  "_x": {
-                    "type": "string"
-                  }
-                },
-                "required": [
-                  "_x"
-                ]
+                "$ref": "#/components/schemas/BodyParam"
               }
             }
           },
@@ -288,6 +404,86 @@
   },
   "components": {
     "schemas": {
+      "BodyParam": {
+        "type": "object",
+        "properties": {
+          "_x": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "_x"
+        ]
+      },
+      "NeverDuplicatedBodyNextLevel": {
+        "type": "object",
+        "properties": {
+          "_v": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "_v"
+        ]
+      },
+      "NeverDuplicatedBodyTopLevel": {
+        "type": "object",
+        "properties": {
+          "_b": {
+            "$ref": "#/components/schemas/NeverDuplicatedBodyNextLevel"
+          }
+        },
+        "required": [
+          "_b"
+        ]
+      },
+      "NeverDuplicatedNext": {
+        "type": "object",
+        "properties": {
+          "_v": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "_v"
+        ]
+      },
+      "NeverDuplicatedResponseNextLevel": {
+        "type": "object",
+        "properties": {
+          "v": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "v"
+        ]
+      },
+      "NeverDuplicatedResponseTopLevel": {
+        "type": "object",
+        "properties": {
+          "b": {
+            "$ref": "#/components/schemas/NeverDuplicatedResponseNextLevel"
+          }
+        },
+        "required": [
+          "b"
+        ]
+      },
+      "NeverDuplicatedTop": {
+        "type": "object",
+        "properties": {
+          "_b": {
+            "$ref": "#/components/schemas/NeverDuplicatedNext"
+          }
+        },
+        "required": [
+          "_b"
+        ]
+      },
+      "Response": {
+        "type": "object"
+      },
       "ResponseItem": {
         "type": "object",
         "properties": {
@@ -298,6 +494,37 @@
         "required": [
           "word"
         ]
+      },
+      "ResponseItemResultsPage": {
+        "description": "A single page of results",
+        "type": "object",
+        "properties": {
+          "items": {
+            "description": "list of items on this page of results",
+            "type": "array",
+            "items": {
+              "$ref": "#/components/schemas/ResponseItem"
+            }
+          },
+          "next_page": {
+            "description": "token used to fetch the next page of results (if any)",
+            "type": "string"
+          }
+        },
+        "required": [
+          "items"
+        ]
+      },
+      "NeverDuplicatedParamNextLevel": {
+        "type": "object",
+        "properties": {
+          "_v": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "_v"
+        ]
       }
     }
   }
diff --git a/dropshot/tests/test_pagination_schema.json b/dropshot/tests/test_pagination_schema.json
index d1f8e674c..10a7211ef 100644
--- a/dropshot/tests/test_pagination_schema.json
+++ b/dropshot/tests/test_pagination_schema.json
@@ -52,25 +52,7 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "title": "ResponseItemResultsPage",
-                  "description": "A single page of results",
-                  "type": "object",
-                  "properties": {
-                    "items": {
-                      "description": "list of items on this page of results",
-                      "type": "array",
-                      "items": {
-                        "$ref": "#/components/schemas/ResponseItem"
-                      }
-                    },
-                    "next_page": {
-                      "description": "token used to fetch the next page of results (if any)",
-                      "type": "string"
-                    }
-                  },
-                  "required": [
-                    "items"
-                  ]
+                  "$ref": "#/components/schemas/ResponseItemResultsPage"
                 }
               }
             }
@@ -93,6 +75,26 @@
           "word"
         ]
       },
+      "ResponseItemResultsPage": {
+        "description": "A single page of results",
+        "type": "object",
+        "properties": {
+          "items": {
+            "description": "list of items on this page of results",
+            "type": "array",
+            "items": {
+              "$ref": "#/components/schemas/ResponseItem"
+            }
+          },
+          "next_page": {
+            "description": "token used to fetch the next page of results (if any)",
+            "type": "string"
+          }
+        },
+        "required": [
+          "items"
+        ]
+      },
       "GarbageGoesIn": {
         "type": "string",
         "enum": [