From 9b64eded3b73ed50510c59079584bdab7fa8088c Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Thu, 15 Aug 2024 08:27:58 +0200 Subject: [PATCH] Disable Swagger-UI + eliminate "duplicate operation ID" warnings in Quarkus tests (#9340) Disable the Swagger-UI in Quarkus, because the OpenAPI path "mismatches" between the interfaces in the `org.projectnessie.api.v1/2.http` packages and the implementations in the `org.projectnessie.services.rest` package. Those do either produce duplicate entries in the Swagger UI or, when only including the implementations via `mp.openapi.scan.include`, no entries. Also, the Swagger UI is rather a dev-mode thing and is only available on the Quarkus management port and therefore not publicly available. Also disables the Quarkus provided download of the OpenAPI yaml and replaced it with a download of the `openapi.yaml` from `:nessie-model`. --- .../rest/IcebergApiV1GenericResource.java | 6 ++++ .../rest/IcebergApiV1NamespaceResource.java | 8 ++++- .../rest/IcebergApiV1S3SignResource.java | 2 ++ .../rest/IcebergApiV1TableResource.java | 18 ++++++++--- .../rest/IcebergApiV1ViewResource.java | 12 +++++-- .../rest/IcebergExtV1GenericResource.java | 3 ++ servers/quarkus-server/build.gradle.kts | 31 +++++++------------ .../src/main/resources/application.properties | 21 +++++++++---- site/docs/develop/rest.md | 9 +++--- site/docs/guides/ui.md | 2 +- site/in-dev/configuration.md | 4 +-- 11 files changed, 75 insertions(+), 41 deletions(-) diff --git a/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergApiV1GenericResource.java b/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergApiV1GenericResource.java index 29412b7f3f6..cc2875e48ac 100644 --- a/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergApiV1GenericResource.java +++ b/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergApiV1GenericResource.java @@ -35,6 +35,7 @@ import jakarta.ws.rs.core.Response; import java.io.IOException; import java.util.Map; +import org.eclipse.microprofile.openapi.annotations.Operation; import org.jboss.resteasy.reactive.server.ServerExceptionMapper; import org.projectnessie.api.v2.params.ParsedReference; import org.projectnessie.catalog.formats.iceberg.rest.IcebergCatalogOperation; @@ -63,6 +64,7 @@ public Response mapException(Exception ex) { } /** Exposes the Iceberg REST configuration for the Nessie default branch. */ + @Operation(operationId = "iceberg.v1.getConfig") @GET @Path("/v1/config") public IcebergConfigResponse getConfig(@QueryParam("warehouse") String warehouse) { @@ -73,6 +75,7 @@ public IcebergConfigResponse getConfig(@QueryParam("warehouse") String warehouse * Exposes the Iceberg REST configuration for the named Nessie {@code reference} in the * {@code @Path} parameter. */ + @Operation(operationId = "iceberg.v1.getConfig.reference") @GET @Path("{reference}/v1/config") public IcebergConfigResponse getConfig( @@ -83,6 +86,7 @@ public IcebergConfigResponse getConfig( .build(); } + @Operation(operationId = "iceberg.v1.getToken") @POST @Path("/v1/oauth/tokens") @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @@ -99,6 +103,7 @@ public Response getToken() { .build(); } + @Operation(operationId = "iceberg.v1.getToken.reference") @POST @Path("/{reference}/v1/oauth/tokens") @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @@ -107,6 +112,7 @@ public Response getToken(@PathParam("reference") String ignored) { return getToken(); } + @Operation(operationId = "iceberg.v1.commitTransaction") @POST @Path("/v1/{prefix}/transactions/commit") @Blocking diff --git a/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergApiV1NamespaceResource.java b/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergApiV1NamespaceResource.java index 24dbee84813..688c9e6ff28 100644 --- a/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergApiV1NamespaceResource.java +++ b/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergApiV1NamespaceResource.java @@ -40,6 +40,7 @@ import java.util.List; import java.util.Map; import java.util.stream.Stream; +import org.eclipse.microprofile.openapi.annotations.Operation; import org.jboss.resteasy.reactive.server.ServerExceptionMapper; import org.projectnessie.api.v2.params.ParsedReference; import org.projectnessie.catalog.formats.iceberg.meta.IcebergNamespace; @@ -68,13 +69,13 @@ public class IcebergApiV1NamespaceResource extends IcebergApiV1ResourceBase { @Inject IcebergErrorMapper errorMapper; - @Inject IcebergConfigurer icebergConfigurer; @ServerExceptionMapper public Response mapException(Exception ex) { return errorMapper.toResponse(ex, IcebergEntityKind.NAMESPACE); } + @Operation(operationId = "iceberg.v1.createNamespace") @POST @Path("/v1/{prefix}/namespaces") @Blocking @@ -101,6 +102,7 @@ public IcebergCreateNamespaceResponse createNamespace( .build(); } + @Operation(operationId = "iceberg.v1.dropNamespace") @DELETE @Path("/v1/{prefix}/namespaces/{namespace}") @Blocking @@ -117,6 +119,7 @@ public void dropNamespace( .delete(); } + @Operation(operationId = "iceberg.v1.listNamespaces") @GET @Path("/v1/{prefix}/namespaces") @Blocking @@ -148,6 +151,7 @@ public IcebergListNamespacesResponse listNamespaces( return response.build(); } + @Operation(operationId = "iceberg.v1.namespaceExists") @HEAD @Path("/v1/{prefix}/namespaces/{namespace}") @Blocking @@ -164,6 +168,7 @@ public void namespaceExists( .get(); } + @Operation(operationId = "iceberg.v1.loadNamespaceMetadata") @GET @Path("/v1/{prefix}/namespaces/{namespace}") @Blocking @@ -235,6 +240,7 @@ public IcebergGetNamespaceResponse loadNamespaceMetadata( .build(); } + @Operation(operationId = "iceberg.v1.updateNamespaceProperties") @POST @Path("/v1/{prefix}/namespaces/{namespace}/properties") @Blocking diff --git a/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergApiV1S3SignResource.java b/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergApiV1S3SignResource.java index 99546b0784f..e37429ad4d4 100644 --- a/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergApiV1S3SignResource.java +++ b/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergApiV1S3SignResource.java @@ -32,6 +32,7 @@ import java.time.Clock; import java.util.List; import java.util.Optional; +import org.eclipse.microprofile.openapi.annotations.Operation; import org.jboss.resteasy.reactive.server.ServerExceptionMapper; import org.projectnessie.catalog.files.api.RequestSigner; import org.projectnessie.catalog.formats.iceberg.rest.IcebergS3SignRequest; @@ -67,6 +68,7 @@ public Response mapException(Exception ex) { return errorMapper.toResponse(ex, IcebergEntityKind.UNKNOWN); } + @Operation(operationId = "iceberg.v1.s3sign") @POST @Path("/v1/{prefix}/s3-sign/{identifier}") @Blocking diff --git a/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergApiV1TableResource.java b/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergApiV1TableResource.java index 15410c52231..444b6c0671e 100644 --- a/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergApiV1TableResource.java +++ b/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergApiV1TableResource.java @@ -67,6 +67,7 @@ import java.util.Map; import java.util.Optional; import java.util.function.ToIntFunction; +import org.eclipse.microprofile.openapi.annotations.Operation; import org.jboss.resteasy.reactive.server.ServerExceptionMapper; import org.projectnessie.api.v2.params.ParsedReference; import org.projectnessie.catalog.formats.iceberg.meta.IcebergJson; @@ -101,7 +102,8 @@ import org.projectnessie.model.ContentKey; import org.projectnessie.model.ContentResponse; import org.projectnessie.model.IcebergTable; -import org.projectnessie.model.Operation; +import org.projectnessie.model.Operation.Delete; +import org.projectnessie.model.Operation.Put; import org.projectnessie.storage.uri.StorageUri; /** Handles Iceberg REST API v1 endpoints that are associated with tables. */ @@ -119,6 +121,7 @@ public Response mapException(Exception ex) { return errorMapper.toResponse(ex, IcebergEntityKind.TABLE); } + @Operation(operationId = "iceberg.v1.loadTable") @GET @Path("/v1/{prefix}/namespaces/{namespace}/tables/{table}") @Blocking @@ -250,6 +253,7 @@ private ContentResponse fetchIcebergTable(TableRef tableRef, boolean forWrite) return fetchIcebergEntity(tableRef, ICEBERG_TABLE, "table", forWrite); } + @Operation(operationId = "iceberg.v1.createTable") @POST @Path("/v1/{prefix}/namespaces/{namespace}/tables") @Blocking @@ -342,6 +346,7 @@ public Uni createTable( true)); } + @Operation(operationId = "iceberg.v1.registerTable") @POST @Path("/v1/{prefix}/namespaces/{namespace}/register") @Blocking @@ -389,7 +394,7 @@ public Uni registerTable( format( "Register Iceberg table '%s' from '%s'", ctr.contentKey(), registerTableRequest.metadataLocation()))) - .operation(Operation.Put.of(ctr.contentKey(), newContent)) + .operation(Put.of(ctr.contentKey(), newContent)) .commitWithResponse(); return this.loadTable( @@ -436,7 +441,7 @@ public Uni registerTable( format( "Register Iceberg table '%s' from '%s'", tableRef.contentKey(), registerTableRequest.metadataLocation()))) - .operation(Operation.Put.of(tableRef.contentKey(), newContent)) + .operation(Put.of(tableRef.contentKey(), newContent)) .commitWithResponse(); return this.loadTable( @@ -452,6 +457,7 @@ public Uni registerTable( true); } + @Operation(operationId = "iceberg.v1.dropTable") @DELETE @Path("/v1/{prefix}/namespaces/{namespace}/tables/{table}") @Blocking @@ -470,10 +476,11 @@ public void dropTable( .commitMultipleOperations() .branch(ref) .commitMeta(fromMessage(format("Drop ICEBERG_TABLE %s", tableRef.contentKey()))) - .operation(Operation.Delete.of(tableRef.contentKey())) + .operation(Delete.of(tableRef.contentKey())) .commitWithResponse(); } + @Operation(operationId = "iceberg.v1.listTables") @GET @Path("/v1/{prefix}/namespaces/{namespace}/tables") @Blocking @@ -509,6 +516,7 @@ public void renameTable( renameContent(prefix, renameTableRequest, ICEBERG_TABLE); } + @Operation(operationId = "iceberg.v1.tableExists") @HEAD @Path("/v1/{prefix}/namespaces/{namespace}/tables/{table}") @Blocking @@ -522,6 +530,7 @@ public void tableExists( fetchIcebergTable(tableRef, false); } + @Operation(operationId = "iceberg.v1.tableMetrics") @POST @Path("/v1/{prefix}/namespaces/{namespace}/tables/{table}/metrics") @Blocking @@ -544,6 +553,7 @@ private void pushMetrics(TableRef tableRef, IcebergMetricsReport report) { // TODO note that metrics for "staged tables" are also received, even if those do not yet exist } + @Operation(operationId = "iceberg.v1.updateTable") @POST @Path("/v1/{prefix}/namespaces/{namespace}/tables/{table}") @Blocking diff --git a/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergApiV1ViewResource.java b/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergApiV1ViewResource.java index 5616ff06fc7..0ac3904e092 100644 --- a/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergApiV1ViewResource.java +++ b/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergApiV1ViewResource.java @@ -50,6 +50,7 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import org.eclipse.microprofile.openapi.annotations.Operation; import org.jboss.resteasy.reactive.server.ServerExceptionMapper; import org.projectnessie.catalog.formats.iceberg.meta.IcebergViewMetadata; import org.projectnessie.catalog.formats.iceberg.rest.IcebergCommitViewRequest; @@ -67,7 +68,7 @@ import org.projectnessie.model.ContentKey; import org.projectnessie.model.ContentResponse; import org.projectnessie.model.IcebergView; -import org.projectnessie.model.Operation; +import org.projectnessie.model.Operation.Delete; /** Handles Iceberg REST API v1 endpoints that are associated with views. */ @RequestScoped @@ -83,6 +84,7 @@ public Response mapException(Exception ex) { return errorMapper.toResponse(ex, IcebergEntityKind.VIEW); } + @Operation(operationId = "iceberg.v1.createView") @POST @Path("/v1/{prefix}/namespaces/{namespace}/views") @Blocking @@ -137,6 +139,7 @@ private IcebergLoadViewResponse loadViewResult( return builder.metadata(viewMetadata).metadataLocation(metadataLocation).build(); } + @Operation(operationId = "iceberg.v1.dropView") @DELETE @Path("/v1/{prefix}/namespaces/{namespace}/views/{view}") @Blocking @@ -155,7 +158,7 @@ public void dropView( .commitMultipleOperations() .branch(ref) .commitMeta(fromMessage(format("Drop ICEBERG_VIEW %s", tableRef.contentKey()))) - .operation(Operation.Delete.of(tableRef.contentKey())) + .operation(Delete.of(tableRef.contentKey())) .commitWithResponse(); } @@ -164,6 +167,7 @@ private ContentResponse fetchIcebergView(TableRef tableRef, boolean forWrite) return fetchIcebergEntity(tableRef, ICEBERG_VIEW, "view", forWrite); } + @Operation(operationId = "iceberg.v1.listViews") @GET @Path("/v1/{prefix}/namespaces/{namespace}/views") @Blocking @@ -189,6 +193,7 @@ public IcebergListTablesResponse listViews( return response.build(); } + @Operation(operationId = "iceberg.v1.loadView") @GET @Path("/v1/{prefix}/namespaces/{namespace}/views/{view}") @Blocking @@ -212,6 +217,7 @@ private Uni loadView(TableRef tableRef) throws NessieNo .map(snap -> loadViewResultFromSnapshotResponse(snap, IcebergLoadViewResponse.builder())); } + @Operation(operationId = "iceberg.v1.renameView") @POST @Path("/v1/{prefix}/views/rename") @Blocking @@ -223,6 +229,7 @@ public void renameView( renameContent(prefix, renameTableRequest, ICEBERG_VIEW); } + @Operation(operationId = "iceberg.v1.viewExists") @HEAD @Path("/v1/{prefix}/namespaces/{namespace}/views/{view}") @Blocking @@ -236,6 +243,7 @@ public void viewExists( fetchIcebergView(tableRef, false); } + @Operation(operationId = "iceberg.v1.updateView") @POST @Path("/v1/{prefix}/namespaces/{namespace}/views/{view}") @Blocking diff --git a/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergExtV1GenericResource.java b/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergExtV1GenericResource.java index 0baf0d20d9a..3a13928949a 100644 --- a/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergExtV1GenericResource.java +++ b/catalog/service/rest/src/main/java/org/projectnessie/catalog/service/rest/IcebergExtV1GenericResource.java @@ -25,6 +25,7 @@ import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.openapi.annotations.Operation; import org.jboss.resteasy.reactive.server.ServerExceptionMapper; import org.projectnessie.catalog.service.rest.IcebergErrorMapper.IcebergEntityKind; @@ -46,6 +47,7 @@ public Response mapException(Exception ex) { return errorMapper.toResponse(ex, IcebergEntityKind.UNKNOWN); } + @Operation(operationId = "iceberg-ext.v1.trinoConfig") @GET @Path("/v1/client-template/trino") @Produces(MediaType.TEXT_PLAIN) @@ -54,6 +56,7 @@ public Response getTrinoConfig( return getTrinoConfig(null, warehouse, format); } + @Operation(operationId = "iceberg-ext.v1.trinoConfig.reference") @GET @Path("{reference}/v1/client-template/trino") @Produces(MediaType.WILDCARD) diff --git a/servers/quarkus-server/build.gradle.kts b/servers/quarkus-server/build.gradle.kts index a212c46bc4e..cdb7008dbc7 100644 --- a/servers/quarkus-server/build.gradle.kts +++ b/servers/quarkus-server/build.gradle.kts @@ -90,7 +90,7 @@ dependencies { compileOnly(libs.immutables.value.annotations) annotationProcessor(libs.immutables.value.processor) - openapiSource(project(":nessie-model-quarkus", "openapiSource")) + openapiSource(project(":nessie-model-quarkus")) { isTransitive = false } testFixturesApi(platform(libs.junit.bom)) testFixturesApi(libs.bundles.junit.testing) @@ -161,26 +161,22 @@ dependencies { intTestImplementation("software.amazon.awssdk:sts") } -val openApiSpecDir = layout.buildDirectory.dir("openapi-extra") - val pullOpenApiSpec by tasks.registering(Sync::class) { - destinationDir = openApiSpecDir.get().asFile - from(openapiSource) { include("openapi.yaml") } + inputs.files(openapiSource) + destinationDir = layout.buildDirectory.dir("resources/openapi").get().asFile + from(provider { zipTree(openapiSource.singleFile) }) { + include("META-INF/openapi/**") + eachFile { path = "META-INF/resources/nessie-openapi/${file.name}" } + } } +sourceSets.named("main") { resources.srcDir(pullOpenApiSpec) } + +tasks.named("processResources") { dependsOn(pullOpenApiSpec) } + quarkus { quarkusBuildProperties.put("quarkus.package.type", quarkusPackageType()) - quarkusBuildProperties.put( - "quarkus.smallrye-openapi.store-schema-directory", - layout.buildDirectory.asFile.map { it.resolve("openapi") }.get().toString() - ) - quarkusBuildProperties.put( - "quarkus.smallrye-openapi.additional-docs-directory", - openApiSpecDir.get().toString() - ) - quarkusBuildProperties.put("quarkus.smallrye-openapi.info-version", project.version.toString()) - quarkusBuildProperties.put("quarkus.smallrye-openapi.auto-add-security", "false") // Pull manifest attributes from the "main" `jar` task to get the // release-information into the jars generated by Quarkus. quarkusBuildProperties.putAll( @@ -202,11 +198,6 @@ val quarkusBuild = tasks.named("quarkusBuild") quarkusDependenciesBuild.configure { dependsOn("processJandexIndex") } -quarkusAppPartsBuild.configure { - dependsOn(pullOpenApiSpec) - inputs.files(openapiSource) -} - // Expose runnable jar via quarkusRunner configuration for integration-tests that require the // server. artifacts { diff --git a/servers/quarkus-server/src/main/resources/application.properties b/servers/quarkus-server/src/main/resources/application.properties index d66d9bc8b0c..6da81a2dbe1 100644 --- a/servers/quarkus-server/src/main/resources/application.properties +++ b/servers/quarkus-server/src/main/resources/application.properties @@ -303,10 +303,21 @@ quarkus.management.enabled=true quarkus.management.port=9000 quarkus.management.test-port=0 -## Quarkus swagger settings -# fixed at buildtime -quarkus.swagger-ui.always-include=true -quarkus.swagger-ui.enable=true +## Quarkus Swagger-UI settings +# Disabled, because the OpenAPI path "mismatches" between the interfaces in the `org.projectnessie.api.v1/2.http` +# packages and the implementations in the `org.projectnessie.services.rest` package. Those do either produce +# duplicate entries in the Swagger UI or, when only including the implementations via mp.openapi.scan.include, no +# entries. Also, the Swagger UI is rather a dev-mode thing and is only available on the Quarkus management port and +# therefore not publicly available. +quarkus.swagger-ui.always-include=false +quarkus.swagger-ui.enable=false +# The /q/openapi endpoint is disabled for the same reasons +quarkus.smallrye-openapi.enable=false + +# OpenAPI +mp.openapi.extensions.smallrye.operationIdStrategy=METHOD +# Don't scan the interfaces, because scanning those causes the annoying "SROAP07903: Duplicate operationId" warning. +mp.openapi.scan.exclude.packages=org.projectnessie.api.v1.http,org.projectnessie.api.v2.http quarkus.application.name=Nessie @@ -356,8 +367,6 @@ quarkus.otel.traces.sampler.arg=1.0d #nessie.version.store.events.retry.initial-delay=PT1S #nessie.version.store.events.retry.max-delay=PT5S -mp.openapi.extensions.smallrye.operationIdStrategy=METHOD - # order matters below, since the first matching pattern will be used quarkus.micrometer.binder.http-server.match-patterns=\ /api/v2/trees/.*/contents/.*=/api/v2/trees/{ref}/contents/{key},\ diff --git a/site/docs/develop/rest.md b/site/docs/develop/rest.md index 42a0dcbc9f2..da29f8139d8 100644 --- a/site/docs/develop/rest.md +++ b/site/docs/develop/rest.md @@ -1,9 +1,8 @@ # Rest API Nessie's REST APIs are how all applications interact with Nessie. The APIs are specified -according to the openapi v3 standard and are available when running the server by going -to [localhost:19120/q/openapi](http://localhost:9000/q/openapi). You can also peruse the set of operations our APIs support -by going to [SwaggerHub](https://app.swaggerhub.com/apis/projectnessie/nessie). +according to the openapi v3 standard and are available when running the server by at the path +`/nessie-openapi/openapi.yaml` (for example via `curl http://127.0.0.1:19120//nessie-openapi/openapi.yaml`) +and on the Download/Release pages. -If you are working in development, our Quarkus server will automatically start with -the swagger-ui for experimentation. You can find that at [localhost:9000/q/swagger-ui/](http://localhost:9000/q/swagger-ui/) +The API can also be inspected at [SwaggerHub](https://app.swaggerhub.com/apis/projectnessie/nessie). diff --git a/site/docs/guides/ui.md b/site/docs/guides/ui.md index 9e64e60d3ea..59fa3b836a5 100644 --- a/site/docs/guides/ui.md +++ b/site/docs/guides/ui.md @@ -11,4 +11,4 @@ you can find the UI at [localhost:19120](http://localhost:19120/). ### Swagger UI The Swagger UI allows for testing the REST API and reading the API docs. It is available -at [localhost:9000/q/swagger-ui](http://localhost:9000/q/swagger-ui/). +at [SwaggerHub](https://app.swaggerhub.com/apis/projectnessie/nessie). diff --git a/site/in-dev/configuration.md b/site/in-dev/configuration.md index 8b6845335d3..8cfd7cfd3d0 100644 --- a/site/in-dev/configuration.md +++ b/site/in-dev/configuration.md @@ -389,7 +389,7 @@ running and that the URL is correct. ### Swagger UI The Swagger UI allows for testing the REST API and reading the API docs. It is available -via [localhost:9000/q/swagger-ui](http://localhost:9000/q/swagger-ui/) +at [SwaggerHub](https://app.swaggerhub.com/apis/projectnessie/nessie). ## Docker image options @@ -474,4 +474,4 @@ quarkus.log.category."io.smallrye.config".level=DEBUG !!! warn This will print out all configuration values, including sensitive ones like passwords. Don't - do this in production, and don't share this output with anyone you don't trust! \ No newline at end of file + do this in production, and don't share this output with anyone you don't trust!