diff --git a/build.gradle b/build.gradle
index 9dd53b0a2c46d6b2cec7cf056b61633a7f4f6eea..d501fb01b2424d06d35114748b54e062a5afa56f 100644
--- a/build.gradle
+++ b/build.gradle
@@ -36,7 +36,6 @@ plugins {
   id "com.palantir.git-version" version "${gitVersionVersion}" apply false
   id "com.star-zero.gradle.githook" version "${githooksVersion}"
   id "eclipse-wtp"
-  id "io.micronaut.application" version "${micronautPluginVersion}"
   id "jacoco"
   id "maven-publish"
 }
diff --git a/gradle.properties b/gradle.properties
index 8b9194ffdc924b15cc7dd4c401439a767c47cf5a..66bdffc1bb41b0bdb74b642a849d23e66ad99aa0 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -8,12 +8,13 @@ jacksonVersion = 2.9.0
 junitVersion = 5.8.2
 micronautVersion = 3.2.3
 micronautRxVersion = 2.1.1
-micronautPluginVersion = 3.1.1
 nodePluginVersion = 3.0.1
 nodeVersion = 16.3.0
 nshmpLibVersion = 1.1.1
-nshmpWsUtilsVersion = 0.3.7
+nshmpWsUtilsVersion = 0.3.9
+openApiVersion = 4.0.0
 shadowVersion = 7.1.2
 spotbugsVersion = 4.7.0
 spotlessVersion = 6.0.4
-swaggerVersion = 2.2.0
+swaggerParserVersion = 2.0.30
+swaggerVersion = 2.1.7
diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle
index af19505cd1bef520ab22beb648c802e286e8c3b5..4e5fb88aee8c360e87aad8fc31d0706cac8b2c95 100644
--- a/gradle/dependencies.gradle
+++ b/gradle/dependencies.gradle
@@ -13,10 +13,10 @@ dependencies {
   implementation "com.amazonaws:aws-java-sdk-ec2:${awsEc2Version}"
 
   // Micronaut
-  annotationProcessor platform("io.micronaut:micronaut-bom")
+  annotationProcessor platform("io.micronaut:micronaut-bom:${micronautVersion}")
   annotationProcessor "io.micronaut:micronaut-inject-java"
   annotationProcessor "io.micronaut:micronaut-validation"
-  implementation platform("io.micronaut:micronaut-bom")
+  implementation platform("io.micronaut:micronaut-bom:${micronautVersion}")
   implementation "io.micronaut:micronaut-http-client"
   implementation "io.micronaut:micronaut-inject"
   implementation "io.micronaut:micronaut-validation"
@@ -28,13 +28,15 @@ dependencies {
   runtimeOnly "ch.qos.logback:logback-classic"
 
   // Swagger
-  annotationProcessor("io.micronaut.openapi:micronaut-openapi")
-  implementation("io.swagger.core.v3:swagger-annotations")
-  implementation("io.swagger.core.v3:swagger-models")
+  annotationProcessor("io.micronaut.openapi:micronaut-openapi:${openApiVersion}")
+  implementation("io.micronaut.openapi:micronaut-openapi")
+  implementation("io.swagger.core.v3:swagger-annotations:${swaggerVersion}")
+  implementation("io.swagger.core.v3:swagger-models:${swaggerVersion}")
+  implementation("io.swagger.parser.v3:swagger-parser:${swaggerParserVersion}")
 
   // junit
-  testAnnotationProcessor "io.micronaut:micronaut-inject-java"
-  testImplementation platform("io.micronaut:micronaut-bom")
+  testAnnotationProcessor "io.micronaut:micronaut-inject-java:${micronautVersion}"
+  testImplementation platform("io.micronaut:micronaut-bom:${micronautVersion}")
   testImplementation "org.junit.jupiter:junit-jupiter:${junitVersion}"
   testImplementation "org.junit.jupiter:junit-jupiter-api"
   testImplementation "io.micronaut.test:micronaut-test-junit5"
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/ServicesUtil.java b/src/main/java/gov/usgs/earthquake/nshmp/www/ServicesUtil.java
index 3bdcab696e214e4d4940244a3ad557070552a54b..35032bae952860beb17a42ff8cf7b0a5a814eb0f 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/ServicesUtil.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/ServicesUtil.java
@@ -57,6 +57,14 @@ public class ServicesUtil {
       longitude = query.longitude;
       latitude = query.latitude;
     }
+
+    public double getLongitude() {
+      return longitude;
+    }
+
+    public double getLatitude() {
+      return latitude;
+    }
   }
 
   public enum Key {
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/ServletUtil.java b/src/main/java/gov/usgs/earthquake/nshmp/www/ServletUtil.java
index 4896b9c81f8cc55e173db2bfe2b0b29a8cff94a6..8eaadb3536dc61b8c90d66af93bed58b041eb2ad 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/ServletUtil.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/ServletUtil.java
@@ -184,7 +184,7 @@ public class ServletUtil {
     return imt.toString();
   }
 
-  public static Object serverData(int threads, Stopwatch timer) {
+  public static Server serverData(int threads, Stopwatch timer) {
     return new Server(threads, timer);
   }
 
@@ -198,7 +198,7 @@ public class ServletUtil {
     return builder.build();
   }
 
-  private static class Server {
+  public static class Server {
 
     final int threads;
     final String timer;
@@ -209,5 +209,17 @@ public class ServletUtil {
       this.timer = timer.toString();
       this.version = new HazVersion().getVersionInfo().version;
     }
+
+    public int getThreads() {
+      return threads;
+    }
+
+    public String getTimer() {
+      return timer;
+    }
+
+    public String getVersion() {
+      return version;
+    }
   }
 }
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/SwaggerController.java b/src/main/java/gov/usgs/earthquake/nshmp/www/SwaggerController.java
deleted file mode 100644
index dc4d6fb3cdf414424b09634b25c7d34bdd67f97c..0000000000000000000000000000000000000000
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/SwaggerController.java
+++ /dev/null
@@ -1,48 +0,0 @@
-package gov.usgs.earthquake.nshmp.www;
-
-import java.nio.charset.StandardCharsets;
-import java.util.stream.Collectors;
-
-import org.slf4j.LoggerFactory;
-
-import com.google.common.io.Resources;
-
-import io.micronaut.http.HttpRequest;
-import io.micronaut.http.HttpResponse;
-import io.micronaut.http.MediaType;
-import io.micronaut.http.annotation.Controller;
-import io.micronaut.http.annotation.Get;
-import io.swagger.v3.oas.annotations.Hidden;
-import io.swagger.v3.oas.annotations.tags.Tag;
-import jakarta.inject.Inject;
-
-/**
- * Expose OpenAPI YAML file.
- *
- * @author U.S. Geological Survey
- */
-@Tag(
-    name = "Swagger",
-    description = "Swagger OpenAPI YAML")
-@Hidden
-@Controller("/swagger")
-public class SwaggerController {
-
-  @Inject
-  private NshmpMicronautServlet servlet;
-
-  @Get(produces = MediaType.TEXT_EVENT_STREAM)
-  public HttpResponse<String> doGet(HttpRequest<?> request) {
-    try {
-      var url = Resources.getResource("META-INF/swagger/nshmp-haz.yml");
-      var yml = Resources.readLines(url, StandardCharsets.UTF_8)
-          .stream()
-          .collect(Collectors.joining("\n"));
-      return HttpResponse.ok(yml);
-    } catch (Exception e) {
-      return ServletUtil.error(
-          LoggerFactory.getLogger("Swagger"),
-          e, "Swagger", request.getUri().toString());
-    }
-  }
-}
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/DisaggController.java b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/DisaggController.java
index 5d55dc9a93ace2c69a4771b3ee972b0ba985ddf2..cedda9cb3037f726b07909c60303aa7fb8f1cf82 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/DisaggController.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/DisaggController.java
@@ -8,9 +8,13 @@ import java.util.Set;
 import gov.usgs.earthquake.nshmp.calc.DataType;
 import gov.usgs.earthquake.nshmp.gmm.Imt;
 import gov.usgs.earthquake.nshmp.www.NshmpMicronautServlet;
+import gov.usgs.earthquake.nshmp.www.ResponseBody;
 import gov.usgs.earthquake.nshmp.www.ServletUtil;
 import gov.usgs.earthquake.nshmp.www.hazard.DisaggService.DisaggDataType;
-import gov.usgs.earthquake.nshmp.www.hazard.HazardService.HazardImt;
+import gov.usgs.earthquake.nshmp.www.hazard.DisaggService.RequestIml;
+import gov.usgs.earthquake.nshmp.www.hazard.DisaggService.RequestRp;
+import gov.usgs.earthquake.nshmp.www.hazard.DisaggService.Response;
+import gov.usgs.earthquake.nshmp.www.hazard.HazardService.Metadata;
 
 import io.micronaut.core.annotation.Nullable;
 import io.micronaut.http.HttpRequest;
@@ -21,6 +25,7 @@ import io.micronaut.http.annotation.Get;
 import io.micronaut.http.annotation.PathVariable;
 import io.micronaut.http.annotation.QueryValue;
 import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
 import io.swagger.v3.oas.annotations.media.Schema;
 import io.swagger.v3.oas.annotations.responses.ApiResponse;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -43,10 +48,13 @@ public class DisaggController {
 
   @Operation(
       summary = "Disaggregation model and service metadata",
-      description = "Returns details of the installed model and service request parameters")
+      description = "Returns details of the installed model and service request parameters",
+      operationId = "disagg-metadata")
   @ApiResponse(
       description = "Disaggregation service metadata",
-      responseCode = "200")
+      responseCode = "200",
+      content = @Content(
+          schema = @Schema(implementation = MetadataResponse.class)))
   @Get(produces = MediaType.APPLICATION_JSON)
   public HttpResponse<String> doGetMetadata(HttpRequest<?> http) {
     try {
@@ -75,7 +83,9 @@ public class DisaggController {
       description = "Returns a hazard disaggregation computed from the installed model")
   @ApiResponse(
       description = "Disaggregation",
-      responseCode = "200")
+      responseCode = "200",
+      content = @Content(
+          schema = @Schema(implementation = DisaggResponseReturnPeriod.class)))
   @Get(
       uri = "{longitude}/{latitude}/{vs30}/{returnPeriod}{?imt}",
       produces = MediaType.APPLICATION_JSON)
@@ -93,16 +103,15 @@ public class DisaggController {
       @Schema(
           minimum = "150",
           maximum = "3000") @PathVariable double returnPeriod,
-      @QueryValue @Nullable Set<HazardImt> imt,
+      @QueryValue @Nullable Set<Imt> imt,
       @QueryValue @Nullable Set<DisaggDataType> out) {
     try {
       Set<Imt> imts = HazardService.readImts(http);
       Set<DataType> dataTypes = HazardService.readDataTypes(http);
       DisaggService.RequestRp request = new DisaggService.RequestRp(
           http,
-          longitude, latitude, vs30,
+          longitude, latitude, vs30, imts,
           returnPeriod,
-          imts,
           dataTypes);
       return DisaggService.getDisaggRp(request);
     } catch (Exception e) {
@@ -124,7 +133,9 @@ public class DisaggController {
       description = "Returns a hazard disaggregation computed from the installed model")
   @ApiResponse(
       description = "Disaggregation",
-      responseCode = "200")
+      responseCode = "200",
+      content = @Content(
+          schema = @Schema(implementation = DisaggResponseIml.class)))
   @Get(
       uri = "{longitude}/{latitude}/{vs30}",
       produces = MediaType.APPLICATION_JSON)
@@ -139,20 +150,9 @@ public class DisaggController {
       @Schema(
           minimum = "150",
           maximum = "3000") @PathVariable double vs30,
-      @QueryValue @Nullable Double PGA,
-      @QueryValue @Nullable Double SA0P2,
-      @QueryValue @Nullable Double SA1P0,
-      @QueryValue @Nullable Double SA5P0,
+      @Schema(
+          example = "{\"PGA\": 0, \"SA0P2\": 0, \"SA1P0\": 0, \"SA2P0\": 0}") @QueryValue @Nullable Map<Imt, Double> imls,
       @QueryValue @Nullable Set<DisaggDataType> out) {
-
-    /*
-     * Developer notes:
-     *
-     * It is awkward to support IMT=#; numerous unique keys that may or may not
-     * be present yields a clunky swagger interface. The disagg-iml endpoint
-     * requires one or more IMT=# query arguments. Document in example.
-     */
-
     try {
       Map<Imt, Double> imtImlMap = http.getParameters().asMap(Imt.class, Double.class);
       checkArgument(!imtImlMap.isEmpty(), "No IMLs supplied");
@@ -170,4 +170,13 @@ public class DisaggController {
           http.getUri().toString());
     }
   }
+
+  // For Swagger schema
+  private static class DisaggResponseIml extends ResponseBody<RequestIml, Response> {}
+
+  // For Swagger schema
+  private static class DisaggResponseReturnPeriod extends ResponseBody<RequestRp, Response> {}
+
+  // For Swagger schema
+  private static class MetadataResponse extends ResponseBody<String, Metadata> {};
 }
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/DisaggService.java b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/DisaggService.java
index 8a41630eafe3eb942596e0562f42a12ce8026f0a..abd46fdc89ac8b523365d3dad427869ed14a5f76 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/DisaggService.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/DisaggService.java
@@ -32,6 +32,8 @@ import gov.usgs.earthquake.nshmp.www.HazVersion;
 import gov.usgs.earthquake.nshmp.www.ResponseBody;
 import gov.usgs.earthquake.nshmp.www.ResponseMetadata;
 import gov.usgs.earthquake.nshmp.www.ServletUtil;
+import gov.usgs.earthquake.nshmp.www.ServletUtil.Server;
+import gov.usgs.earthquake.nshmp.www.hazard.HazardService.HazardRequest;
 import gov.usgs.earthquake.nshmp.www.hazard.HazardService.Metadata;
 import gov.usgs.earthquake.nshmp.www.meta.Parameter;
 
@@ -202,12 +204,7 @@ public final class DisaggService {
     return disagg;
   }
 
-  static final class RequestIml {
-
-    final transient HttpRequest<?> http;
-    final double longitude;
-    final double latitude;
-    final double vs30;
+  static final class RequestIml extends HazardRequest {
     final Map<Imt, Double> imls;
     final Set<DataType> dataTypes;
 
@@ -218,48 +215,57 @@ public final class DisaggService {
         double vs30,
         Map<Imt, Double> imls,
         Set<DataType> dataTypes) {
+      super(http, longitude, latitude, vs30);
 
-      this.http = http;
-      this.longitude = longitude;
-      this.latitude = latitude;
-      this.vs30 = vs30;
       this.imls = imls;
       this.dataTypes = dataTypes;
     }
-  }
 
-  static final class RequestRp {
+    public Map<Imt, Double> getImls() {
+      return imls;
+    }
+
+    public Set<DataType> getDataTypes() {
+      return dataTypes;
+    }
+  }
 
-    final transient HttpRequest<?> http;
-    final double longitude;
-    final double latitude;
-    final double vs30;
+  static final class RequestRp extends HazardRequest {
     final double returnPeriod;
-    final Set<Imt> imts;
     final Set<DataType> dataTypes;
+    final Set<Imt> imts;
 
     RequestRp(
         HttpRequest<?> http,
         double longitude,
         double latitude,
         double vs30,
-        double returnPeriod,
         Set<Imt> imts,
+        double returnPeriod,
         Set<DataType> dataTypes) {
+      super(http, longitude, latitude, vs30);
 
-      this.http = http;
-      this.longitude = longitude;
-      this.latitude = latitude;
-      this.vs30 = vs30;
       this.returnPeriod = returnPeriod;
+      this.dataTypes = dataTypes;
       this.imts = imts.isEmpty()
           ? ServletUtil.model().config().hazard.imts
           : imts;
-      this.dataTypes = dataTypes;
+    }
+
+    public double getReturnPeriod() {
+      return returnPeriod;
+    }
+
+    public Set<DataType> getDataTypes() {
+      return dataTypes;
+    }
+
+    public Set<Imt> getImts() {
+      return imts;
     }
   }
 
-  private static final class Response {
+  static final class Response {
     final Response.Metadata metadata;
     final List<ImtDisagg> disaggs;
 
@@ -268,17 +274,46 @@ public final class DisaggService {
       this.disaggs = disaggs;
     }
 
+    public Response.Metadata getMetadata() {
+      return metadata;
+    }
+
+    public List<ImtDisagg> getDisaggs() {
+      return disaggs;
+    }
+
     private static final class Metadata {
-      final Object server;
+      final Server server;
       final String rlabel = "Closest Distance, rRup (km)";
       final String mlabel = "Magnitude (Mw)";
       final String εlabel = "% Contribution to Hazard";
       final Object εbins;
 
-      Metadata(Object server, Object εbins) {
+      Metadata(Server server, Object εbins) {
         this.server = server;
         this.εbins = εbins;
       }
+
+      public Server getServer() {
+        return server;
+      }
+
+      public String getRLabel() {
+        return rlabel;
+      }
+
+      public String getMLabel() {
+        return mlabel;
+      }
+
+      public String getεLabel() {
+        return εlabel;
+      }
+
+      public Object getεbins() {
+        return εbins;
+      }
+
     }
 
     private static final class Builder {
@@ -326,7 +361,7 @@ public final class DisaggService {
                 dataTypes.contains(DISAGG_DATA))))
             .collect(toList());
 
-        Object server = ServletUtil.serverData(ServletUtil.THREAD_COUNT, timer);
+        var server = ServletUtil.serverData(ServletUtil.THREAD_COUNT, timer);
 
         return new Response(
             new Response.Metadata(server, disagg.εBins()),
@@ -345,5 +380,13 @@ public final class DisaggService {
           imt.name());
       this.data = data;
     }
+
+    public Parameter getImt() {
+      return imt;
+    }
+
+    public Object getData() {
+      return data;
+    }
   }
 }
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/HazardController.java b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/HazardController.java
index 03bc22142d9da072cdd6991f2269d389f1981095..e0fc167011d762a5d1d40c599ac7a16d5d299753 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/HazardController.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/HazardController.java
@@ -4,8 +4,11 @@ import java.util.Set;
 
 import gov.usgs.earthquake.nshmp.gmm.Imt;
 import gov.usgs.earthquake.nshmp.www.NshmpMicronautServlet;
+import gov.usgs.earthquake.nshmp.www.ResponseBody;
 import gov.usgs.earthquake.nshmp.www.ServletUtil;
-import gov.usgs.earthquake.nshmp.www.hazard.HazardService.HazardImt;
+import gov.usgs.earthquake.nshmp.www.hazard.HazardService.Metadata;
+import gov.usgs.earthquake.nshmp.www.hazard.HazardService.Request;
+import gov.usgs.earthquake.nshmp.www.hazard.HazardService.Response;
 
 import io.micronaut.core.annotation.Nullable;
 import io.micronaut.http.HttpRequest;
@@ -16,6 +19,7 @@ import io.micronaut.http.annotation.Get;
 import io.micronaut.http.annotation.PathVariable;
 import io.micronaut.http.annotation.QueryValue;
 import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
 import io.swagger.v3.oas.annotations.media.Schema;
 import io.swagger.v3.oas.annotations.responses.ApiResponse;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -38,10 +42,13 @@ public class HazardController {
 
   @Operation(
       summary = "Hazard model and service metadata",
-      description = "Returns details of the installed model and service request parameters")
+      description = "Returns details of the installed model and service request parameters",
+      operationId = "hazard-metadata")
   @ApiResponse(
       description = "Hazard service metadata",
-      responseCode = "200")
+      responseCode = "200",
+      content = @Content(
+          schema = @Schema(implementation = MetadataResponse.class)))
   @Get(produces = MediaType.APPLICATION_JSON)
   public HttpResponse<String> doGetMetadata(HttpRequest<?> http) {
     try {
@@ -63,14 +70,15 @@ public class HazardController {
    * @param imt Optional IMTs at which to compute hazard. If none are supplied,
    *        then the supported set for the installed model is used. Responses
    *        for numerous IMT's are quite large, on the order of MB.
-   *
    */
   @Operation(
       summary = "Compute probabilisitic hazard at a site",
       description = "Returns hazard curves computed from the installed model")
   @ApiResponse(
       description = "Hazard curves",
-      responseCode = "200")
+      responseCode = "200",
+      content = @Content(
+          schema = @Schema(implementation = HazardResponse.class)))
   @Get(
       uri = "/{longitude}/{latitude}/{vs30}{?truncate,maxdir,imt}",
       produces = MediaType.APPLICATION_JSON)
@@ -89,14 +97,14 @@ public class HazardController {
           defaultValue = "false") @Nullable Boolean truncate,
       @QueryValue(
           defaultValue = "false") @Nullable Boolean maxdir,
-      @QueryValue @Nullable Set<HazardImt> imt) {
+      @QueryValue @Nullable Set<Imt> imt) {
     try {
       Set<Imt> imts = HazardService.readImts(http);
       HazardService.Request request = new HazardService.Request(
           http,
           longitude, latitude, vs30,
-          truncate, maxdir,
-          imts);
+          imts,
+          truncate, maxdir);
       return HazardService.getHazard(request);
     } catch (Exception e) {
       return ServletUtil.error(
@@ -105,4 +113,10 @@ public class HazardController {
           http.getUri().toString());
     }
   }
+
+  // For Swagger schemas
+  private static class HazardResponse extends ResponseBody<Request, Response> {}
+
+  // For Swagger schemas
+  private static class MetadataResponse extends ResponseBody<String, Metadata> {};
 }
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/HazardService.java b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/HazardService.java
index 28a2f6100431f0172379d3abaf7f6d53f4789f46..3f06ebd44b15cc80a8d86eb2a1fbcff0c59165a5 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/HazardService.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/HazardService.java
@@ -38,6 +38,7 @@ import gov.usgs.earthquake.nshmp.www.HazVersion;
 import gov.usgs.earthquake.nshmp.www.ResponseBody;
 import gov.usgs.earthquake.nshmp.www.ResponseMetadata;
 import gov.usgs.earthquake.nshmp.www.ServletUtil;
+import gov.usgs.earthquake.nshmp.www.ServletUtil.Server;
 import gov.usgs.earthquake.nshmp.www.meta.DoubleParameter;
 import gov.usgs.earthquake.nshmp.www.meta.Parameter;
 import gov.usgs.earthquake.nshmp.www.services.SourceServices.SourceModel;
@@ -60,33 +61,6 @@ public final class HazardService {
 
   private static final String TOTAL_KEY = "Total";
 
-  /* For Swagger selections; mprs + pgv */
-  enum HazardImt {
-    PGA,
-    PGV,
-    SA0P01,
-    SA0P02,
-    SA0P03,
-    SA0P05,
-    SA0P075,
-    SA0P1,
-    SA0P15,
-    SA0P2,
-    SA0P25,
-    SA0P3,
-    SA0P4,
-    SA0P5,
-    SA0P75,
-    SA1P0,
-    SA1P5,
-    SA2P0,
-    SA3P0,
-    SA4P0,
-    SA5P0,
-    SA7P5,
-    SA10P0;
-  }
-
   /** HazardController.doGetUsage() handler. */
   public static HttpResponse<String> getMetadata(HttpRequest<?> request) {
     var url = request.getUri().toString();
@@ -182,14 +156,55 @@ public final class HazardService {
           150,
           1500);
     }
-  }
 
-  static final class Request {
+    public SourceModel getModel() {
+      return model;
+    }
+
+    public DoubleParameter getLongitude() {
+      return longitude;
+    }
+
+    public DoubleParameter getLatitude() {
+      return latitude;
+    }
 
+    public DoubleParameter getVs30() {
+      return vs30;
+    }
+  }
+
+  static class HazardRequest {
     final transient HttpRequest<?> http;
     final double longitude;
     final double latitude;
     final double vs30;
+
+    public HazardRequest(
+        HttpRequest<?> http,
+        double longitude,
+        double latitude,
+        double vs30) {
+      this.http = http;
+      this.longitude = checkLongitude(longitude);
+      this.latitude = checkLatitude(latitude);
+      this.vs30 = checkInRange(Site.VS30_RANGE, Site.Key.VS30, vs30);
+    }
+
+    public double getLongitude() {
+      return longitude;
+    }
+
+    public double getLatitude() {
+      return latitude;
+    }
+
+    public double getVs30() {
+      return vs30;
+    }
+  }
+
+  static final class Request extends HazardRequest {
     final boolean truncate;
     final boolean maxdir;
     final Set<Imt> imts;
@@ -198,24 +213,32 @@ public final class HazardService {
         HttpRequest<?> http,
         double longitude,
         double latitude,
-        int vs30,
+        double vs30,
+        Set<Imt> imts,
         boolean truncate,
-        boolean maxdir,
-        Set<Imt> imts) {
-
-      this.http = http;
-      this.longitude = checkLongitude(longitude);
-      this.latitude = checkLatitude(latitude);
-      this.vs30 = checkInRange(Site.VS30_RANGE, Site.Key.VS30, vs30);
+        boolean maxdir) {
+      super(http, longitude, latitude, vs30);
       this.truncate = truncate;
       this.maxdir = maxdir;
       this.imts = imts.isEmpty()
           ? ServletUtil.model().config().hazard.imts
           : imts;
     }
+
+    public boolean getTruncate() {
+      return truncate;
+    }
+
+    public boolean getMaxdir() {
+      return maxdir;
+    }
+
+    public Set<Imt> getImts() {
+      return imts;
+    }
   }
 
-  private static final class Response {
+  static class Response {
 
     final Metadata metadata;
     final List<ImtCurves> hazardCurves;
@@ -225,14 +248,34 @@ public final class HazardService {
       this.hazardCurves = hazardCurves;
     }
 
+    public Metadata getMetadata() {
+      return metadata;
+    }
+
+    public List<ImtCurves> getHazardCurves() {
+      return hazardCurves;
+    }
+
     private static final class Metadata {
-      final Object server;
+      final Server server;
       final String xlabel = "Ground Motion (g)";
       final String ylabel = "Annual Frequency of Exceedance";
 
-      Metadata(Object server) {
+      Metadata(Server server) {
         this.server = server;
       }
+
+      public Server getServer() {
+        return server;
+      }
+
+      public String getXLabel() {
+        return xlabel;
+      }
+
+      public String getYLabel() {
+        return ylabel;
+      }
     }
 
     private static final class Builder {
@@ -305,7 +348,7 @@ public final class HazardService {
           hazards.add(new ImtCurves(imt, curves));
         }
 
-        Object server = ServletUtil.serverData(ServletUtil.THREAD_COUNT, timer);
+        var server = ServletUtil.serverData(ServletUtil.THREAD_COUNT, timer);
         var response = new Response(
             new Response.Metadata(server),
             hazards);
@@ -324,6 +367,14 @@ public final class HazardService {
       this.imt = new Parameter(ServletUtil.imtShortLabel(imt), imt.name());
       this.data = data;
     }
+
+    public Parameter getImt() {
+      return imt;
+    }
+
+    public List<Curve> getData() {
+      return data;
+    }
   }
 
   private static final class Curve {
@@ -334,6 +385,14 @@ public final class HazardService {
       this.component = component;
       this.values = values;
     }
+
+    public String getComponent() {
+      return component;
+    }
+
+    public XySequence getValues() {
+      return values;
+    }
   }
 
   private static final double TRUNCATION_LIMIT = 1e-4;
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/meta/DoubleParameter.java b/src/main/java/gov/usgs/earthquake/nshmp/www/meta/DoubleParameter.java
index f38749853663d6a04568990e0c628a2f63e1058e..0f234031c6b59b40626187833c60156368c46faa 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/meta/DoubleParameter.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/meta/DoubleParameter.java
@@ -13,4 +13,20 @@ public final class DoubleParameter {
     this.min = min;
     this.max = max;
   }
+
+  public String getName() {
+    return name;
+  }
+
+  public String getUnits() {
+    return units;
+  }
+
+  public double getMin() {
+    return min;
+  }
+
+  public double getMax() {
+    return max;
+  }
 }
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/meta/Metadata.java b/src/main/java/gov/usgs/earthquake/nshmp/www/meta/Metadata.java
index 0f2d40b1d6eeb39cd7d7de5d222542b0ef49f266..194b98e89003f8920513d3026edffd7a2166af2e 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/meta/Metadata.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/meta/Metadata.java
@@ -52,6 +52,14 @@ public final class Metadata {
           Coordinates.LAT_RANGE.lowerEndpoint(),
           Coordinates.LAT_RANGE.upperEndpoint());
     }
+
+    public DoubleParameter getLongitude() {
+      return longitude;
+    }
+
+    public DoubleParameter getLatitude() {
+      return latitude;
+    }
   }
 
   public static String errorMessage(String url, Throwable e, boolean trace) {
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/meta/Parameter.java b/src/main/java/gov/usgs/earthquake/nshmp/www/meta/Parameter.java
index c6463c3ba318bf035b232c09f845d3b8563854f7..60d8553752cdb4137bb8b7426e5f10e366f27544 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/meta/Parameter.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/meta/Parameter.java
@@ -9,4 +9,12 @@ public class Parameter {
     this.display = display;
     this.value = value;
   }
+
+  public String getDisplay() {
+    return display;
+  }
+
+  public String getValue() {
+    return value;
+  }
 }
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/RateController.java b/src/main/java/gov/usgs/earthquake/nshmp/www/services/RateController.java
similarity index 75%
rename from src/main/java/gov/usgs/earthquake/nshmp/www/RateController.java
rename to src/main/java/gov/usgs/earthquake/nshmp/www/services/RateController.java
index df15c1fdeb68712fe60169d4310be79e947482de..7bf98ecc6828ff574703446dc7319d4b2fa70c94 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/RateController.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/services/RateController.java
@@ -1,10 +1,16 @@
-package gov.usgs.earthquake.nshmp.www;
+package gov.usgs.earthquake.nshmp.www.services;
 
 import java.util.Optional;
 
-import gov.usgs.earthquake.nshmp.www.services.RateService;
+import gov.usgs.earthquake.nshmp.www.NshmpMicronautServlet;
+import gov.usgs.earthquake.nshmp.www.ResponseBody;
+import gov.usgs.earthquake.nshmp.www.services.RateService.ProbabilityParameters;
 import gov.usgs.earthquake.nshmp.www.services.RateService.Query;
+import gov.usgs.earthquake.nshmp.www.services.RateService.RateParameters;
+import gov.usgs.earthquake.nshmp.www.services.RateService.RequestData;
+import gov.usgs.earthquake.nshmp.www.services.RateService.ResponseData;
 import gov.usgs.earthquake.nshmp.www.services.RateService.Service;
+import gov.usgs.earthquake.nshmp.www.services.RateService.Usage;
 
 import io.micronaut.core.annotation.Nullable;
 import io.micronaut.http.HttpRequest;
@@ -15,6 +21,7 @@ import io.micronaut.http.annotation.Get;
 import io.micronaut.http.annotation.PathVariable;
 import io.micronaut.http.annotation.QueryValue;
 import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
 import io.swagger.v3.oas.annotations.media.Schema;
 import io.swagger.v3.oas.annotations.responses.ApiResponse;
 import jakarta.inject.Inject;
@@ -32,24 +39,6 @@ public class RateController {
   @Inject
   private NshmpMicronautServlet servlet;
 
-  /**
-   * GET method to return the usage information on the rate service.
-   *
-   * @param request The HTTP request
-   */
-  @Operation(
-      summary = "Returns the earthquake rate service usage",
-      description = "Returns the supported:\n * Cutoff distance\n * Longitude\n * Latitude",
-      operationId = "rate_doGetUsageRate",
-      tags = { "Rate Service" })
-  @ApiResponse(
-      description = "Earthquake rate usage",
-      responseCode = "200")
-  @Get(uri = "/rate/usage", produces = MediaType.APPLICATION_JSON)
-  public HttpResponse<String> doGetUsageRate(HttpRequest<?> request) {
-    return RateService.handleDoGetUsage(request, Service.RATE);
-  }
-
   /**
    * GET method to compute annual-rate, query based.
    *
@@ -64,8 +53,19 @@ public class RateController {
       operationId = "rate_doGetRate",
       tags = { "Rate Service" })
   @ApiResponse(
-      description = "Earthquake annual-rates",
-      responseCode = "200")
+      description = "Earthquake annual-rates service metadata",
+      responseCode = "20x",
+      content = {
+          @Content(
+              schema = @Schema(implementation = RateMetadataResponse.class))
+      })
+  @ApiResponse(
+      description = "Earthquake annual-rates calculation response",
+      responseCode = "200",
+      content = {
+          @Content(
+              schema = @Schema(implementation = CalcResponse.class))
+      })
   @Get(
       uri = "/rate{?longitude,latitude,distance}",
       produces = MediaType.APPLICATION_JSON)
@@ -102,8 +102,10 @@ public class RateController {
       operationId = "rate_doGetRateSlash",
       tags = { "Rate Service" })
   @ApiResponse(
-      description = "Earthquake annual-rates",
-      responseCode = "200")
+      description = "Earthquake annual-rates calculation response",
+      responseCode = "200",
+      content = @Content(
+          schema = @Schema(implementation = CalcResponse.class)))
   @Get(
       uri = "/rate{/longitude}{/latitude}{/distance}",
       produces = MediaType.APPLICATION_JSON)
@@ -126,25 +128,6 @@ public class RateController {
     return RateService.handleDoGetCalc(request, query);
   }
 
-  /**
-   * GET method to return the usage information on the probability service.
-   *
-   * @param request The HTTP request
-   */
-  @Operation(
-      summary = "Returns the earthquake probability service usage",
-      description = "Returns the supported:\n " +
-          "* Timespan\n * Cutoff distance\n * Longitude\n * Latitude",
-      operationId = "probability_doGetProbabilityRate",
-      tags = { "Probability Service" })
-  @ApiResponse(
-      description = "Earthquake probability usage",
-      responseCode = "200")
-  @Get(uri = "/probability/usage", produces = MediaType.APPLICATION_JSON)
-  public HttpResponse<String> doGetUsageProbability(HttpRequest<?> request) {
-    return RateService.handleDoGetUsage(request, Service.PROBABILITY);
-  }
-
   /**
    * GET method to compute probability, query based.
    *
@@ -160,8 +143,20 @@ public class RateController {
       operationId = "probability_doGetProbability",
       tags = { "Probability Service" })
   @ApiResponse(
-      description = "Earthquake probabilities",
-      responseCode = "200")
+      description = "Earthquake probabilities service metadata",
+      responseCode = "20x",
+      content = {
+          @Content(
+              schema = @Schema(implementation = ProbMetadataResponse.class))
+      })
+  @ApiResponse(
+      description = "Earthquake probabilities calculation response",
+      responseCode = "200",
+      content = {
+          @Content(
+              schema = @Schema(
+                  implementation = CalcResponse.class))
+      })
   @Get(
       uri = "/probability{?longitude,latitude,distance,timespan}",
       produces = MediaType.APPLICATION_JSON)
@@ -203,8 +198,11 @@ public class RateController {
       operationId = "probability_doGetProbabilitySlash",
       tags = { "Probability Service" })
   @ApiResponse(
-      description = "Earthquake probabilities",
-      responseCode = "200")
+      description = "Earthquake probabilities calculation response",
+      responseCode = "200",
+      content = @Content(
+          schema = @Schema(
+              implementation = CalcResponse.class)))
   @Get(
       uri = "/probability{/longitude}{/latitude}{/distance}{/timespan}",
       produces = MediaType.APPLICATION_JSON)
@@ -231,4 +229,11 @@ public class RateController {
     return RateService.handleDoGetCalc(request, query);
   }
 
+  // Swagger schemas
+  private static class CalcResponse extends ResponseBody<RequestData, ResponseData> {}
+
+  private static class RateMetadataResponse extends ResponseBody<String, Usage<RateParameters>> {};
+
+  private static class ProbMetadataResponse extends
+      ResponseBody<String, Usage<ProbabilityParameters>> {};
 }
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/services/RateService.java b/src/main/java/gov/usgs/earthquake/nshmp/www/services/RateService.java
index b4ce06c52b3314701941246fa723215577df9967..a084a6a0aa74963840b7f4697b54fd34a6f8b64b 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/services/RateService.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/services/RateService.java
@@ -19,13 +19,13 @@ import gov.usgs.earthquake.nshmp.calc.Site;
 import gov.usgs.earthquake.nshmp.geo.Location;
 import gov.usgs.earthquake.nshmp.model.HazardModel;
 import gov.usgs.earthquake.nshmp.www.HazVersion;
-import gov.usgs.earthquake.nshmp.www.RateController;
 import gov.usgs.earthquake.nshmp.www.ResponseBody;
 import gov.usgs.earthquake.nshmp.www.ResponseMetadata;
 import gov.usgs.earthquake.nshmp.www.ServicesUtil.Key;
 import gov.usgs.earthquake.nshmp.www.ServicesUtil.ServiceQueryData;
 import gov.usgs.earthquake.nshmp.www.ServicesUtil.ServiceRequestData;
 import gov.usgs.earthquake.nshmp.www.ServletUtil;
+import gov.usgs.earthquake.nshmp.www.ServletUtil.Server;
 import gov.usgs.earthquake.nshmp.www.WsUtils;
 import gov.usgs.earthquake.nshmp.www.meta.DoubleParameter;
 import gov.usgs.earthquake.nshmp.www.meta.Metadata.DefaultParameters;
@@ -56,23 +56,6 @@ public final class RateService {
 
   private static final String TOTAL_KEY = "Total";
 
-  /**
-   * Handler for {@link RateController#doGetUsageRate} and
-   * {@link RateController#doGetUsageProbability}.
-   *
-   * @param service The service
-   * @param urlHelper The url helper
-   */
-  public static HttpResponse<String> handleDoGetUsage(HttpRequest<?> request, Service service) {
-    try {
-      var response = metadata(request, service);
-      var json = ServletUtil.GSON.toJson(response);
-      return HttpResponse.ok(json);
-    } catch (Exception e) {
-      return ServletUtil.error(LOG, e, service.name, request.getUri().getPath());
-    }
-  }
-
   /**
    * Handler for {@link RateController#doGetProbability},
    * {@link RateController#doGetProbabilitySlash},
@@ -83,7 +66,7 @@ public final class RateService {
    * @param urlHelper The url helper
    * @return
    */
-  public static HttpResponse<String> handleDoGetCalc(HttpRequest<?> request, Query query) {
+  static HttpResponse<String> handleDoGetCalc(HttpRequest<?> request, Query query) {
     var service = query.service;
 
     try {
@@ -102,11 +85,12 @@ public final class RateService {
     }
   }
 
-  static ResponseBody<String, Usage> metadata(HttpRequest<?> request, Service service) {
+  static ResponseBody<String, Usage<DefaultParameters>> metadata(HttpRequest<?> request,
+      Service service) {
     var parameters = service == Service.RATE ? new RateParameters() : new ProbabilityParameters();
-    var usage = new Usage(service, parameters);
+    var usage = new Usage<DefaultParameters>(service, parameters);
     var url = request.getUri().getPath();
-    return ResponseBody.<String, Usage> usage()
+    return ResponseBody.<String, Usage<DefaultParameters>> usage()
         .name(service.name)
         .url(url)
         .metadata(new ResponseMetadata(HazVersion.appVersions()))
@@ -167,6 +151,12 @@ public final class RateService {
     return ratesCombined;
   }
 
+  private static HttpResponse<String> handleDoGetUsage(HttpRequest<?> request, Service service) {
+    var response = metadata(request, service);
+    var json = ServletUtil.GSON.toJson(response);
+    return HttpResponse.ok(json);
+  }
+
   private static ListenableFuture<EqRate> process(
       Service service,
       HazardModel model,
@@ -251,6 +241,14 @@ public final class RateService {
       this.distance = query.distance;
       this.timespan = query.timespan;
     }
+
+    public double getDistance() {
+      return distance;
+    }
+
+    public Optional<Double> getTimespan() {
+      return timespan;
+    }
   }
 
   private static final class ServiceResponseMetadata {
@@ -270,10 +268,34 @@ public final class RateService {
       this.ylabel = isProbability ? "Probability" : "Annual Rate (yr⁻¹)";
       this.timespan = request.timespan.orElse(null);
     }
+
+    public double getLatitude() {
+      return latitude;
+    }
+
+    public double getLongitude() {
+      return longitude;
+    }
+
+    public double getDistance() {
+      return distance;
+    }
+
+    public Double getTimespan() {
+      return timespan;
+    }
+
+    public String getXlabel() {
+      return xlabel;
+    }
+
+    public String getYLabel() {
+      return ylabel;
+    }
   }
 
-  private static final class ResponseData {
-    final Object server;
+  static final class ResponseData {
+    final Server server;
     final ServiceResponseMetadata metadata;
     final List<Sequence> data;
 
@@ -283,6 +305,18 @@ public final class RateService {
       this.data = buildSequence(rates);
     }
 
+    public Server getServer() {
+      return server;
+    }
+
+    public ServiceResponseMetadata getMetadata() {
+      return metadata;
+    }
+
+    public List<Sequence> getData() {
+      return data;
+    }
+
     List<Sequence> buildSequence(EqRate rates) {
       var sequences = new ArrayList<Sequence>();
 
@@ -326,23 +360,45 @@ public final class RateService {
       this.xvalues = xvalues;
       this.yvalues = yvalues;
     }
+
+    public String getComponent() {
+      return component;
+    }
+
+    public List<Double> getXvalues() {
+      return xvalues;
+    }
+
+    public List<Double> getYvalues() {
+      return yvalues;
+    }
   }
 
-  private static class Usage {
+  static class Usage<T extends DefaultParameters> {
     final String description;
     final List<String> syntax;
-    final Object server;
-    final DefaultParameters parameters;
+    final T parameters;
 
-    private Usage(Service service, DefaultParameters parameters) {
+    private Usage(Service service, T parameters) {
       description = service.description;
       this.syntax = service.syntax;
-      server = ServletUtil.serverData(1, Stopwatch.createStarted());
       this.parameters = parameters;
     }
+
+    public String getDescription() {
+      return description;
+    }
+
+    public List<String> getSyntax() {
+      return syntax;
+    }
+
+    public T getParameters() {
+      return parameters;
+    }
   }
 
-  private static class RateParameters extends DefaultParameters {
+  static class RateParameters extends DefaultParameters {
     final DoubleParameter distance;
 
     RateParameters() {
@@ -353,9 +409,13 @@ public final class RateService {
           0.01,
           1000.0);
     }
+
+    public DoubleParameter getDistance() {
+      return distance;
+    }
   }
 
-  private static class ProbabilityParameters extends RateParameters {
+  static class ProbabilityParameters extends RateParameters {
     final DoubleParameter timespan;
 
     ProbabilityParameters() {
@@ -365,6 +425,9 @@ public final class RateService {
           Maths.TIMESPAN_RANGE.lowerEndpoint(),
           Maths.TIMESPAN_RANGE.upperEndpoint());
     }
-  }
 
+    public DoubleParameter getTimespan() {
+      return timespan;
+    }
+  }
 }
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/SourceController.java b/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceController.java
similarity index 71%
rename from src/main/java/gov/usgs/earthquake/nshmp/www/SourceController.java
rename to src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceController.java
index 3c86ffe988cd66edea5179ef4cc64be0d0bbc54f..b6fe870b5d8f6ddc6359971fcbda57f0b4a774c4 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/SourceController.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceController.java
@@ -1,6 +1,8 @@
-package gov.usgs.earthquake.nshmp.www;
+package gov.usgs.earthquake.nshmp.www.services;
 
-import gov.usgs.earthquake.nshmp.www.services.SourceServices;
+import gov.usgs.earthquake.nshmp.www.NshmpMicronautServlet;
+import gov.usgs.earthquake.nshmp.www.ResponseBody;
+import gov.usgs.earthquake.nshmp.www.services.SourceServices.ResponseData;
 
 import io.micronaut.http.HttpRequest;
 import io.micronaut.http.HttpResponse;
@@ -8,6 +10,8 @@ import io.micronaut.http.MediaType;
 import io.micronaut.http.annotation.Controller;
 import io.micronaut.http.annotation.Get;
 import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
 import io.swagger.v3.oas.annotations.responses.ApiResponse;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import jakarta.inject.Inject;
@@ -43,10 +47,15 @@ public class SourceController {
       operationId = "source_doGetUsage")
   @ApiResponse(
       description = "Installed source model",
-      responseCode = "200")
+      responseCode = "200",
+      content = @Content(
+          schema = @Schema(
+              implementation = MetadataResponse.class)))
   @Get(produces = MediaType.APPLICATION_JSON)
   public HttpResponse<String> doGetUsage(HttpRequest<?> request) {
     return SourceServices.handleDoGetUsage(request);
   }
 
+  // For Swagger schemas
+  private static class MetadataResponse extends ResponseBody<String, ResponseData> {}
 }
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/SourceLogicTreesController.java b/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceLogicTreesController.java
similarity index 68%
rename from src/main/java/gov/usgs/earthquake/nshmp/www/SourceLogicTreesController.java
rename to src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceLogicTreesController.java
index 7fe44c003dfce73020bbea90b956299703103535..5cdb6d5107020db30b1f23a536101aa0c7652e36 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/SourceLogicTreesController.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceLogicTreesController.java
@@ -1,6 +1,8 @@
-package gov.usgs.earthquake.nshmp.www;
+package gov.usgs.earthquake.nshmp.www.services;
 
-import gov.usgs.earthquake.nshmp.www.services.SourceLogicTreesService;
+import gov.usgs.earthquake.nshmp.www.NshmpMicronautServlet;
+import gov.usgs.earthquake.nshmp.www.ResponseBody;
+import gov.usgs.earthquake.nshmp.www.services.SourceLogicTreesService.RequestData;
 
 import io.micronaut.http.HttpRequest;
 import io.micronaut.http.HttpResponse;
@@ -8,6 +10,8 @@ import io.micronaut.http.annotation.Controller;
 import io.micronaut.http.annotation.Get;
 import io.micronaut.http.annotation.PathVariable;
 import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
 import io.swagger.v3.oas.annotations.responses.ApiResponse;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import jakarta.inject.Inject;
@@ -33,7 +37,10 @@ public class SourceLogicTreesController {
       summary = "Hazard model source logic trees")
   @ApiResponse(
       description = "Source logic trees metadata",
-      responseCode = "200")
+      responseCode = "200",
+      content = @Content(
+          schema = @Schema(
+              implementation = MetadataResponse.class)))
   @Get
   public HttpResponse<String> doGetMetadata(HttpRequest<?> request) {
     return SourceLogicTreesService.handleDoGetMetadata(request);
@@ -48,9 +55,17 @@ public class SourceLogicTreesController {
       summary = "Get NSHM source logic tree")
   @ApiResponse(
       description = "NSHM source logic tree",
-      responseCode = "200")
+      responseCode = "200",
+      content = @Content(
+          schema = @Schema(implementation = TreeResponse.class)))
   @Get(uri = "/{id}")
   public HttpResponse<String> doGetTrees(HttpRequest<?> request, @PathVariable int id) {
     return SourceLogicTreesService.handleDoGetTrees(request, id);
   }
+
+  // For Swagger schemas
+  private static class MetadataResponse extends ResponseBody<String, Object> {}
+
+  // For Swagger schemas
+  private static class TreeResponse extends ResponseBody<RequestData, Object> {}
 }
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceLogicTreesService.java b/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceLogicTreesService.java
index 5525e39647022f1523a0969ba0201a94de5fd8bd..8dde682dc8e14cef7c9e075d9cecaf788e478a42 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceLogicTreesService.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceLogicTreesService.java
@@ -8,7 +8,6 @@ import gov.usgs.earthquake.nshmp.www.HazVersion;
 import gov.usgs.earthquake.nshmp.www.ResponseBody;
 import gov.usgs.earthquake.nshmp.www.ResponseMetadata;
 import gov.usgs.earthquake.nshmp.www.ServletUtil;
-import gov.usgs.earthquake.nshmp.www.SourceLogicTreesController;
 
 import io.micronaut.http.HttpRequest;
 import io.micronaut.http.HttpResponse;
@@ -65,11 +64,15 @@ public class SourceLogicTreesService {
     }
   }
 
-  private static class RequestData {
-    int id;
+  static class RequestData {
+    final int id;
 
     RequestData(int id) {
       this.id = id;
     }
+
+    public int getId() {
+      return id;
+    }
   }
 }
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceServices.java b/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceServices.java
index f72a1546e820b09e6283116236b4f415edef8823..525ed76b346e7485281ebdfe972049f4627a4d87 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceServices.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceServices.java
@@ -9,7 +9,6 @@ import java.util.Set;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import com.google.common.base.Stopwatch;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 
@@ -38,9 +37,6 @@ import jakarta.inject.Singleton;
 public class SourceServices {
 
   private static final String NAME = "Source Model";
-  private static final String DESCRIPTION = "Installed source model listing";
-  private static final String SERVICE_DESCRIPTION =
-      "Utilities for querying earthquake source models";
 
   static final Logger LOG = LoggerFactory.getLogger(RateService.class);
 
@@ -55,7 +51,7 @@ public class SourceServices {
         .create();
   }
 
-  public static HttpResponse<String> handleDoGetUsage(HttpRequest<?> request) {
+  static HttpResponse<String> handleDoGetUsage(HttpRequest<?> request) {
     var url = request.getUri().getPath();
     try {
       var response = ResponseBody.usage()
@@ -72,27 +68,29 @@ public class SourceServices {
     }
   }
 
-  /*
-   * task... service metadata should be in same package as services (why
-   * ResponseData is currently public); rename meta package to
-   */
-  public static class ResponseData {
+  static class ResponseData {
     final String description;
-    final Object server;
+    final SourceModel model;
 
     public ResponseData() {
-      this.description = "Installed source model listing";
-      this.server = ServletUtil.serverData(
-          ServletUtil.THREAD_COUNT,
-          Stopwatch.createStarted());
+      description = "Installed source model listing";
+      model = new SourceModel(ServletUtil.model());
+    }
+
+    public String getDescription() {
+      return description;
+    }
+
+    public SourceModel getSourceModel() {
+      return model;
     }
   }
 
   public static class SourceModel {
-    String name;
-    Set<Gmm> gmms;
-    Map<NehrpSiteClass, Double> siteClasses;
-    List<Parameter> imts;
+    final String name;
+    final Set<Gmm> gmms;
+    final Map<NehrpSiteClass, Double> siteClasses;
+    final List<Parameter> imts;
 
     public SourceModel(HazardModel model) {
       name = model.name();
@@ -106,6 +104,22 @@ public class SourceServices {
           .map(imt -> new Parameter(ServletUtil.imtShortLabel(imt), imt.name()))
           .collect(toList());
     }
+
+    public String getName() {
+      return name;
+    }
+
+    public Set<Gmm> getGmms() {
+      return gmms;
+    }
+
+    public Map<NehrpSiteClass, Double> getSiteClasses() {
+      return siteClasses;
+    }
+
+    public List<Parameter> getImts() {
+      return imts;
+    }
   }
 
   enum Attributes {
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/services/SwaggerController.java b/src/main/java/gov/usgs/earthquake/nshmp/www/services/SwaggerController.java
new file mode 100644
index 0000000000000000000000000000000000000000..e9ce647c3411c820b41744c6169eca55c710aafd
--- /dev/null
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/services/SwaggerController.java
@@ -0,0 +1,73 @@
+package gov.usgs.earthquake.nshmp.www.services;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.slf4j.LoggerFactory;
+
+import gov.usgs.earthquake.nshmp.model.HazardModel;
+import gov.usgs.earthquake.nshmp.www.NshmpMicronautServlet;
+import gov.usgs.earthquake.nshmp.www.ServletUtil;
+import gov.usgs.earthquake.nshmp.www.SwaggerUtils;
+
+import io.micronaut.http.HttpRequest;
+import io.micronaut.http.HttpResponse;
+import io.micronaut.http.MediaType;
+import io.micronaut.http.annotation.Controller;
+import io.micronaut.http.annotation.Get;
+import io.swagger.v3.core.util.Yaml;
+import io.swagger.v3.oas.annotations.Hidden;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.parser.OpenAPIV3Parser;
+import jakarta.inject.Inject;
+
+/**
+ * Expose OpenAPI YAML file.
+ *
+ * @author U.S. Geological Survey
+ */
+@Tag(
+    name = "Swagger",
+    description = "Swagger OpenAPI YAML")
+@Hidden
+@Controller("/swagger")
+public class SwaggerController {
+
+  @Inject
+  private NshmpMicronautServlet servlet;
+
+  @Get(produces = MediaType.TEXT_EVENT_STREAM)
+  public HttpResponse<String> doGet(HttpRequest<?> request) {
+    try {
+      var model = ServletUtil.model();
+      var openApi = getOpenAPI(request, model);
+      return HttpResponse.ok(Yaml.pretty(openApi));
+    } catch (Exception e) {
+      return ServletUtil.error(
+          LoggerFactory.getLogger("Swagger"),
+          e, "Swagger", request.getUri().toString());
+    }
+  }
+
+  private OpenAPI getOpenAPI(
+      HttpRequest<?> request,
+      HazardModel model) {
+    var openApi = new OpenAPIV3Parser().read("META-INF/swagger/nshmp-haz.yml");
+    // TODO: Get min and max boundaries
+    // SwaggerUtils.addLocationBounds
+    var components = openApi.getComponents();
+    var schemas = components.getSchemas();
+    SwaggerUtils.siteClassSchema(schemas, List.copyOf(model.siteClasses().keySet()));
+    SwaggerUtils.imtSchema(schemas,
+        model.config().hazard.imts.stream().collect(Collectors.toList()));
+    openApi.servers(null);
+
+    openApi.getInfo().setTitle(model.name() + " Web Services");
+    openApi.getInfo().setDescription(
+        "National Seismic Hazard Model (NSHM) hazard calculations and queries for the " +
+            model.name() + " hazard model.");
+
+    return openApi;
+  }
+}
diff --git a/src/main/resources/swagger/index.css b/src/main/resources/swagger/index.css
index 65f90aca32b0c4145e7f2e7fa437cb2448e84b59..b514b935545bc9401d16f678674db2c91e44f4bb 100644
--- a/src/main/resources/swagger/index.css
+++ b/src/main/resources/swagger/index.css
@@ -12,7 +12,7 @@ body {
 }
 
 #swagger-ui {
-  padding-top: 1em;
+  padding-top: 2em;
   padding-bottom: 50px;
 }
 
@@ -71,3 +71,66 @@ body {
 .nshmp-template-footer a:visited {
   color: white;
 }
+
+/* ---- Disclaimer ---- */
+
+.wrapper {
+  box-sizing: border-box;
+  margin: 0 auto;
+  max-width: 1460px;
+  padding: 0 20px;
+  width: 100%;
+}
+
+@media only screen and (max-width: 1000px) {
+  .alert {
+    line-height: 1.1 !important;
+    font-size: 10px !important;
+  }
+}
+
+.alert {
+  background-color: #fff3cd;
+  border-color: #ffecb5;
+  box-shadow: 0 3px 1px -2px #0003, 0 2px 2px #00000024, 0 1px 5px #0000001f;
+  line-height: 1.5;
+  margin: 1em 1em 0 1em;
+  overflow: hidden;
+  padding: 1.5em 2em;
+  position: relative;
+  top: 50px;
+}
+
+.alert:before {
+  content: '\0e002';
+  font-family: 'Material Icons';
+  font-size: 4.5em;
+  left: -.2em;
+  line-height: 1;
+  opacity: .075;
+  position: absolute;
+  top: -.2em;
+}
+
+details {
+  cursor: pointer;
+  box-shadow: 0 3px 1px -2px #0003, 0 2px 2px #00000024, 0 1px 5px #0000001f;
+}
+
+details:hover {
+  background-color: #f3f3f3;
+}
+
+details[open] {
+  background-color: initial;
+  padding: 0 1.5em 1.5em 1.5em;
+}
+
+summary {
+  font-weight: bold;
+  padding: 1.5em;
+}
+
+details[open] > summary {
+  margin-left: -1.5em;
+}
diff --git a/src/main/resources/swagger/index.html b/src/main/resources/swagger/index.html
index 9abcef98e83f25ea79343e6b65a74aa49b80c690..168a705708fd193226accb25175b23d2fce88467 100644
--- a/src/main/resources/swagger/index.html
+++ b/src/main/resources/swagger/index.html
@@ -18,12 +18,11 @@
     <link
       rel="stylesheet"
       type="text/css"
-      href="https://unpkg.com/swagger-ui-themes@3.0.0/themes/3.x/theme-material.css"
+      href="index.css"
     />
     <link
       rel="stylesheet"
-      type="text/css"
-      href="index.css"
+      href="https://fonts.googleapis.com/icon?family=Material+Icons%7cMerriweather:400,400italic,700%7cSource+Sans+Pro:400,300,700"
     />
   </head>
 
@@ -36,17 +35,29 @@
       </div>
     </header>
 
+    <div class="wrapper">
+      <div class="alert" role="alert">
+        This software is preliminary or provisional and is subject to revision.
+        It is being provided to meet the need for timely best science.
+        The software has not received final approval by the U.S. Geological Survey (USGS).
+        No warranty, expressed or implied, is made by the USGS or the U.S. Government as to
+        the functionality of the software and related material nor shall the fact of release
+        constitute any such warranty. The software is provided on the condition that neither
+        the USGS nor the U.S. Government shall be held liable for any damages resulting from
+        the authorized or unauthorized use of the software.
+      </div>
+    </div>
+
     <div id="swagger-ui"></div>
 
     <footer class="nshmp-template-footer">
-      <a href="https://code.usgs.gov/ghsc/nshmp/nshmp-haz/-/blob/main/LICENSE.md">
+      <a href="https://code.usgs.gov/ghsc/nshmp/nshmp-ws-static/-/blob/main/LICENSE.md">
         License
       </a>
 
-      <a href="https://code.usgs.gov/ghsc/nshmp/nshmp-haz/-/blob/main/DISCLAIMER.md">
+      <a href="https://code.usgs.gov/ghsc/nshmp/nshmp-ws-static/-/blob/main/DISCLAIMER.md">
         Disclaimer
       </a>
-
     </footer>
 
     <script src="index.js"></script>
diff --git a/src/main/resources/swagger/index.js b/src/main/resources/swagger/index.js
index 06e5a1326e3df94485c3d6879f3d3d773b4dcecf..443a1f6f88c3eabe9a95934c8c1678fd09568213 100644
--- a/src/main/resources/swagger/index.js
+++ b/src/main/resources/swagger/index.js
@@ -3,13 +3,17 @@ window.onload = function() {
   contextPath = contextPath.endsWith('/') ? contextPath.slice(0, -1) : contextPath;
 
   const ui = SwaggerUIBundle({
-    url: `./swagger`,
+    defaultModelsExpandDepth: 0,
+    deepLinking: true,
+    // docExpansion: 'full',
     dom_id: '#swagger-ui',
-    tagsSorter: 'alpha',
-    presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
+    layout: 'BaseLayout',
     plugins: [SwaggerUIBundle.plugins.DownloadUrl, updateContextPath(contextPath)],
+    presets: [SwaggerUIBundle.presets.apis],
+    tagsSorter: 'alpha',
+    tryItOutEnabled: true,
     validatorUrl: null,
-    deepLinking: true
+    url: `./swagger`,
   });
 
   window.ui = ui;