From 8f29a00ddce518dd4b4c8e543bc72d4914f44e82 Mon Sep 17 00:00:00 2001 From: David Kleiven Date: Thu, 28 Sep 2023 21:28:07 +0200 Subject: [PATCH] Update documentation, add exception handling, improve tests --- pom.xml | 10 ++ src/main/ApiUtil.kt | 2 +- src/main/App.kt | 26 +-- src/main/resources/openapi/documentation.yaml | 149 ++++++++++++++++-- src/test/AppTest.kt | 58 ++++++- 5 files changed, 224 insertions(+), 21 deletions(-) diff --git a/pom.xml b/pom.xml index 75ad533..a21b2bc 100644 --- a/pom.xml +++ b/pom.xml @@ -59,6 +59,16 @@ ktor-server-swagger-jvm ${ktor.version} + + io.ktor + ktor-server-cors-jvm + ${ktor.version} + + + io.ktor + ktor-server-status-pages-jvm + ${ktor.version} + org.jetbrains.kotlin kotlin-test-junit5 diff --git a/src/main/ApiUtil.kt b/src/main/ApiUtil.kt index 7fd4391..2833cd7 100644 --- a/src/main/ApiUtil.kt +++ b/src/main/ApiUtil.kt @@ -71,7 +71,7 @@ enum class DiagramType { Generic, Substation, VoltageLevel } // Case insensitive enum value matching fun getDiagramType(value: String): DiagramType { return try { - DiagramType.entries.first { item -> item.toString().lowercase() == value.lowercase() } + DiagramType.entries.first { item -> item.toString().lowercase() == value.replace("-", "").lowercase() } } catch (e: NoSuchElementException) { DiagramType.Generic } diff --git a/src/main/App.kt b/src/main/App.kt index cbe63af..7085944 100644 --- a/src/main/App.kt +++ b/src/main/App.kt @@ -5,6 +5,8 @@ import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.plugins.cors.routing.* +import io.ktor.server.plugins.statuspages.* import io.ktor.server.plugins.swagger.* import io.ktor.server.request.* import io.ktor.server.response.* @@ -17,6 +19,17 @@ fun Application.module() { json() } + install(CORS) { + anyHost() + allowHeader(HttpHeaders.ContentType) + } + + install(StatusPages) { + exception { call, cause -> + call.respondText("500: $cause", status = HttpStatusCode.InternalServerError) + } + } + routing { get("/") { call.respondText("Hello, world!") @@ -47,7 +60,7 @@ fun Application.module() { } } - post("/voltage-levels") { + post("/voltage-level-names") { val files = multiPartDataHandler(call.receiveMultipart()) if (files.isEmpty()) { call.response.status(HttpStatusCode.UnprocessableEntity) @@ -77,14 +90,9 @@ fun Application.module() { if (files.isEmpty()) { call.response.status(HttpStatusCode.UnprocessableEntity) } else { - try { - val network = networkFromFileContent(files[0]) - val diagram = singleLineDiagram(diagramType, name, network) - call.respondText(diagram, ContentType.Image.SVG, HttpStatusCode.OK) - } catch (e: PowsyblException) { - call.respondText(e.toString(), ContentType.Text.Plain, HttpStatusCode.BadRequest) - } - + val network = networkFromFileContent(files[0]) + val diagram = singleLineDiagram(diagramType, name, network) + call.respondText(diagram, ContentType.Image.SVG, HttpStatusCode.OK) } } diff --git a/src/main/resources/openapi/documentation.yaml b/src/main/resources/openapi/documentation.yaml index 7b539be..a7415a4 100644 --- a/src/main/resources/openapi/documentation.yaml +++ b/src/main/resources/openapi/documentation.yaml @@ -5,13 +5,78 @@ info: version: "1.0.0" servers: - url: "http://0.0.0.0:8080" + - url: "http://devbox:8080" paths: - /get-buses: + /buses: post: - operationId: getBuses + operationId: buses description: "Return all buses in the network" requestBody: - description: "Form with network sent as file" + $ref: "#/components/networkFile" + responses: + 200: + description: "List with all buses in the network" + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Bus" + 422: + $ref: "#/components/missingNetworkFileResponse" + 500: + $ref: "#/components/internalError" + + /default-load-parameters: + get: + description: "Get default parameters for the load flow calculation" + responses: + 200: + description: "Default load parameters as JSON" + content: + application/json: + type: object + /substation-names: + post: + description: "List all substation names in the network. These names can for instance be used in the diagram methods" + requestBody: + $ref: "#/components/networkFile" + responses: + 200: + description: "List with all substation names" + content: + application/json: + type: array + items: + type: string + 422: + $ref: "#/components/missingNetworkFileResponse" + 500: + $ref: "#/components/internalError" + + /voltage-levels: + post: + description: "List all voltage levels in the network. These names can for instance be used in the diagram methods" + requestBody: + $ref: "#/components/networkFile" + responses: + 200: + description: "List with all voltage levels" + content: + application/json: + type: array + items: + type: string + 422: + $ref: "#/components/missingNetworkFileResponse" + 500: + $ref: "#/components/internalError" + + /run-load-flow: + post: + description: "Run load flow calculation" + requestBody: + description: "Form with network data sent as file" required: true content: multipart/form-data: @@ -21,15 +86,52 @@ paths: network: type: string format: binary + load-parameters: + type: string + required: + - network + responses: - "200": - description: "List with all buses in the network" + 200: + description: "Result of load flow calculation" content: application/json: - schema: - type: array - items: - $ref: "#/components/schemas/Bus" + type: object + 422: + $ref: "#/components/missingNetworkFileResponse" + 500: + $ref: "#/components/internalError" + + /diagram: + post: + description: "Create a network area diagram" + requestBody: + $ref: "#/components/networkFile" + responses: + 200: + $ref: "#/components/svgDiagram" + 422: + $ref: "#/components/missingNetworkFileResponse" + 500: + $ref: "#/components/internalError" + + /diagram/{type}/{name}: + post: + description: "Create a diagram of a named group of components" + requestBody: + $ref: "#/components/networkFile" + responses: + 200: + $ref: "#components/svgDiagram" + 400: + description: "Unknown type or name" + content: + text/plain: + type: string + 422: + $ref: "#/components/missingNetworkFileResponse" + 500: + $ref: "#/components/internalError" components: @@ -47,3 +149,32 @@ components: type: double reactivePower: type: double + networkFile: + description: "Form with network data sent as file" + required: true + content: + multipart/form-data: + schema: + type: object + properties: + network: + type: string + format: binary + missingNetworkFileResponse: + description: "No network file provided" + content: + text/plain: + type: string + + internalError: + description: "An internal error occurred" + content: + text/plain: + type: string + + svgDiagram: + description: "Generated svg figure" + content: + image/svg+xml: + type: string + diff --git a/src/test/AppTest.kt b/src/test/AppTest.kt index 40ab0d1..e941cf1 100644 --- a/src/test/AppTest.kt +++ b/src/test/AppTest.kt @@ -6,6 +6,8 @@ import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.http.content.* import io.ktor.server.testing.* +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.TestFactory import java.io.File import java.nio.file.Paths import java.util.* @@ -38,6 +40,44 @@ class ApplicationTest { assertEquals(HttpStatusCode.UnprocessableEntity, response.status) } + @TestFactory + fun `test missing network in form`() = listOf( + "/buses", + "/run-load-flow", + "/substation-names", + "/voltage-level-names", + "/diagram", + "/diagram/substation/S1", + "/diagram/voltage-level/VL1" + ).map { url -> + DynamicTest.dynamicTest("422 when no network is passed to $url") { + testApplication { + val response = client.submitFormWithBinaryData(url = url, formData = listOf()) + assertEquals(HttpStatusCode.UnprocessableEntity, response.status) + } + } + } + + @TestFactory + fun `test internal server error when file parsing fails`() = listOf( + "/buses", + "/run-load-flow", + "/substation-names", + "/voltage-level-names", + "/diagram", + "/diagram/substation/S1", + "/diagram/voltage-level/VL1" + ).map { url -> + DynamicTest.dynamicTest("500 when file content can not be parsed $url") { + testApplication { + val response = client.submitFormWithBinaryData(url = url, formData = formDataWithEmptyNetwork()) + assertEquals(HttpStatusCode.InternalServerError, response.status) + val body = response.bodyAsText() + assertTrue(body.contains("PowsyblException")) + } + } + } + @Test fun `test receive 14 buses for 14 bus network`() = testApplication { @@ -115,7 +155,9 @@ class ApplicationTest { url = "/diagram/substation/non-existent-station", formData = formDataFromFile(ieeeCdfNetwork14File()) ) - assertEquals(response.status, HttpStatusCode.BadRequest) + assertEquals(HttpStatusCode.InternalServerError, response.status) + val body = response.bodyAsText() + assertTrue(body.contains("Substation 'non-existent-station' not found")) } @Test @@ -146,7 +188,7 @@ class ApplicationTest { fun `test 2 voltage levels extracted`() = testApplication { val response = client.submitFormWithBinaryData( - url = "/voltage-levels", + url = "/voltage-level-names", formData = formDataFromFile(ieeeCdfNetwork14File()) ) assertEquals(response.status, HttpStatusCode.OK) @@ -180,6 +222,18 @@ fun formDataFromFile(file: File): List { } } +fun formDataWithEmptyNetwork(): List { + return formData { + append( + "network", + byteArrayOf(), + Headers.build { + append(HttpHeaders.ContentDisposition, "filename=emptyFile.xiidm") + } + ) + } +} + fun ieeeCdfNetwork14File(): File { // Initialize temporary file val file = File.createTempFile("network", ".xiidm")