From 89311af1464187235049149f74a9046f50c769f5 Mon Sep 17 00:00:00 2001 From: Sonja Happ Date: Thu, 19 Nov 2020 17:23:24 +0100 Subject: [PATCH 01/11] WIP: initial version of result data model, endpoints, methods, validators, middleware #38 --- database/database.go | 2 + database/database_test.go | 18 + database/models.go | 19 +- database/roles.go | 4 + doc/api/docs.go | 678 +++++++++++++++++++++++++- doc/api/responses.go | 8 + doc/api/swagger.json | 676 +++++++++++++++++++++++++ doc/api/swagger.yaml | 447 +++++++++++++++++ routes/dashboard/dashboard_methods.go | 4 +- routes/register.go | 2 + routes/result/result_endpoints.go | 281 +++++++++++ routes/result/result_methods.go | 75 +++ routes/result/result_middleware.go | 37 ++ routes/result/result_test.go | 1 + routes/result/result_validators.go | 63 +++ 15 files changed, 2311 insertions(+), 4 deletions(-) create mode 100644 routes/result/result_endpoints.go create mode 100644 routes/result/result_methods.go create mode 100644 routes/result/result_middleware.go create mode 100644 routes/result/result_test.go create mode 100644 routes/result/result_validators.go diff --git a/database/database.go b/database/database.go index c10bbf9..2dd0f27 100644 --- a/database/database.go +++ b/database/database.go @@ -93,6 +93,7 @@ func DropTables() { DBpool.DropTableIfExists(&User{}) DBpool.DropTableIfExists(&Dashboard{}) DBpool.DropTableIfExists(&Widget{}) + DBpool.DropTableIfExists(&Result{}) // The following statement deletes the many to many relationship between users and scenarios DBpool.DropTableIfExists("user_scenarios") } @@ -107,4 +108,5 @@ func MigrateModels() { DBpool.AutoMigrate(&User{}) DBpool.AutoMigrate(&Dashboard{}) DBpool.AutoMigrate(&Widget{}) + DBpool.AutoMigrate(&Result{}) } diff --git a/database/database_test.go b/database/database_test.go index e3a5c9b..abf8a3b 100644 --- a/database/database_test.go +++ b/database/database_test.go @@ -144,6 +144,8 @@ func TestScenarioAssociations(t *testing.T) { dashboardB := Dashboard{} fileA := File{} fileB := File{} + resultA := Result{} + resultB := Result{} // add scenarios to DB assert.NoError(t, DBpool.Create(&scenarioA).Error) @@ -165,6 +167,10 @@ func TestScenarioAssociations(t *testing.T) { assert.NoError(t, DBpool.Create(&fileA).Error) assert.NoError(t, DBpool.Create(&fileB).Error) + // add results to DB + assert.NoError(t, DBpool.Create(&resultA).Error) + assert.NoError(t, DBpool.Create(&resultB).Error) + // add many-to-many associations between users and scenarios // User HM Scenarios, Scenario HM Users (Many-to-Many) assert.NoError(t, DBpool.Model(&scenarioA).Association("Users").Append(&userA).Error) @@ -184,6 +190,10 @@ func TestScenarioAssociations(t *testing.T) { assert.NoError(t, DBpool.Model(&scenarioA).Association("Dashboards").Append(&dashboardA).Error) assert.NoError(t, DBpool.Model(&scenarioA).Association("Dashboards").Append(&dashboardB).Error) + // Scenario HM Results + assert.NoError(t, DBpool.Model(&scenarioA).Association("Results").Append(&resultA).Error) + assert.NoError(t, DBpool.Model(&scenarioA).Association("Results").Append(&resultB).Error) + var scenario1 Scenario assert.NoError(t, DBpool.Find(&scenario1, 1).Error, fmt.Sprintf("Find Scenario with ID=1")) @@ -218,6 +228,14 @@ func TestScenarioAssociations(t *testing.T) { assert.Fail(t, "Scenario Associations", "Expected to have %v Files. Has %v.", 2, len(files)) } + + // Get results of scenario1 + var results []File + assert.NoError(t, DBpool.Model(&scenario1).Related(&results, "Results").Error) + if len(files) != 2 { + assert.Fail(t, "Scenario Associations", + "Expected to have %v Results. Has %v.", 2, len(results)) + } } func TestICAssociations(t *testing.T) { diff --git a/database/models.go b/database/models.go index 869d8f3..d37c3d4 100644 --- a/database/models.go +++ b/database/models.go @@ -32,8 +32,8 @@ import ( // except the json tags that are needed for serializing the models type Model struct { ID uint `json:"id,omitempty" gorm:"primary_key:true"` - CreatedAt time.Time `json:"-"` - UpdatedAt time.Time `json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` DeletedAt *time.Time `json:"-" sql:"index"` } @@ -71,6 +71,8 @@ type Scenario struct { Dashboards []Dashboard `json:"-" gorm:"foreignkey:ScenarioID" ` // Files that belong to the Scenario (for example images, models, etc.) Files []File `json:"-" gorm:"foreignkey:ScenarioID"` + // Results that belong to the Scenario + Results []Result `json:"-" gorm:"foreignkey:ScenarioID"` } // ComponentConfiguration data model @@ -212,3 +214,16 @@ type File struct { // Width of an image file in pixels (optional) ImageWidth int `json:"imageWidth" gorm:"default:0"` } + +// Result data model +type Result struct { + Model + // JSON snapshots of component configurations used to generate results + ConfigSnapshots []postgres.Jsonb `json:"configSnapshots"` + // Description of results + Description string `json:"description"` + // ID of Scenario to which result belongs + ScenarioID uint `json:"scenarioID"` + // File IDs associated with result + ResultFileIDs pq.Int64Array `json:"resultFileIDs" gorm:"type:integer[]"` +} diff --git a/database/roles.go b/database/roles.go index 2ac9464..e303abc 100644 --- a/database/roles.go +++ b/database/roles.go @@ -45,6 +45,7 @@ const ModelWidget = ModelName("widget") const ModelComponentConfiguration = ModelName("component-configuration") const ModelSignal = ModelName("signal") const ModelFile = ModelName("file") +const ModelResult = ModelName("result") type CRUD string @@ -83,6 +84,7 @@ var Roles = RoleActions{ ModelDashboard: crud, ModelSignal: crud, ModelFile: crud, + ModelResult: crud, }, "User": { ModelUser: _ru_, @@ -95,6 +97,7 @@ var Roles = RoleActions{ ModelDashboard: crud, ModelSignal: crud, ModelFile: crud, + ModelResult: crud, }, "Guest": { ModelScenario: _r__, @@ -107,6 +110,7 @@ var Roles = RoleActions{ ModelUsers: none, ModelSignal: _r__, ModelFile: _r__, + ModelResult: none, }, } diff --git a/doc/api/docs.go b/doc/api/docs.go index a381b6b..e698b73 100644 --- a/doc/api/docs.go +++ b/doc/api/docs.go @@ -1,6 +1,6 @@ // GENERATED BY THE COMMAND ABOVE; DO NOT EDIT // This file was generated by swaggo/swag at -// 2020-11-11 16:32:47.799676915 +0100 CET m=+0.126448240 +// 2020-11-19 17:20:42.626650342 +0100 CET m=+0.093632886 package docs @@ -1439,6 +1439,517 @@ var doc = `{ } } }, + "/results": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "results" + ], + "summary": "Get all results of scenario", + "operationId": "getResults", + "parameters": [ + { + "type": "integer", + "description": "Scenario ID", + "name": "scenarioID", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "Results which belong to scenario", + "schema": { + "$ref": "#/definitions/docs.ResponseResults" + } + }, + "404": { + "description": "Not found", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "422": { + "description": "Unprocessable entity", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "results" + ], + "summary": "Add a result to a scenario", + "operationId": "addResult", + "parameters": [ + { + "description": "Result to be added incl. ID of Scenario", + "name": "inputResult", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/result.addResultRequest" + } + } + ], + "responses": { + "200": { + "description": "Result that was added", + "schema": { + "$ref": "#/definitions/docs.ResponseResult" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "404": { + "description": "Not found", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "422": { + "description": "Unprocessable entity", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + } + } + } + }, + "/results/{resultID}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "results" + ], + "summary": "Get a Result", + "operationId": "getResult", + "parameters": [ + { + "type": "integer", + "description": "Result ID", + "name": "resultID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Result that was requested", + "schema": { + "$ref": "#/definitions/docs.ResponseResult" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "404": { + "description": "Not found", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "422": { + "description": "Unprocessable entity", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "results" + ], + "summary": "Update a result", + "operationId": "updateResult", + "parameters": [ + { + "description": "Result to be updated", + "name": "inputResult", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/result.updateResultRequest" + } + }, + { + "type": "integer", + "description": "Result ID", + "name": "resultID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Result that was updated", + "schema": { + "$ref": "#/definitions/docs.ResponseResult" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "404": { + "description": "Not found", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "422": { + "description": "Unprocessable entity", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "results" + ], + "summary": "Delete a Result", + "operationId": "deleteResult", + "parameters": [ + { + "type": "integer", + "description": "Result ID", + "name": "resultID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Result that was deleted", + "schema": { + "$ref": "#/definitions/docs.ResponseResult" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "404": { + "description": "Not found", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "422": { + "description": "Unprocessable entity", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + } + } + } + }, + "/results/{resultID}/file": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "consumes": [ + "text/plain", + "text/csv", + "application/gzip", + "application/x-gtar", + "application/x-tar", + "application/x-ustar", + "application/zip", + "application/msexcel", + "application/xml", + "application/x-bag" + ], + "produces": [ + "application/json" + ], + "tags": [ + "results" + ], + "summary": "Upload a result file to the DB and associate it with scenario and result", + "operationId": "addResultFile", + "parameters": [ + { + "type": "file", + "description": "File to be uploaded", + "name": "inputFile", + "in": "formData", + "required": true + }, + { + "type": "integer", + "description": "Result ID", + "name": "resultID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Result that was updated", + "schema": { + "$ref": "#/definitions/docs.ResponseResult" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "404": { + "description": "Not found", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "422": { + "description": "Unprocessable entity", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + } + } + } + }, + "/results/{resultID}/file/{fileID}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "text/plain", + "text/csv", + "application/gzip", + "application/x-gtar", + "application/x-tar", + "application/x-ustar", + "application/zip", + "application/msexcel", + "application/xml", + "application/x-bag" + ], + "tags": [ + "results" + ], + "summary": "Download a result file", + "operationId": "getResultFile", + "parameters": [ + { + "type": "integer", + "description": "Result ID", + "name": "resultID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "ID of the file to download", + "name": "fileID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "File that was requested", + "schema": { + "$ref": "#/definitions/docs.ResponseFile" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "404": { + "description": "Not found", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "422": { + "description": "Unprocessable entity", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "results" + ], + "summary": "Delete a result file", + "operationId": "deleteResultFile", + "parameters": [ + { + "type": "integer", + "description": "Result ID", + "name": "resultID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "ID of the file to delete", + "name": "fileID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Result for which file was deleted", + "schema": { + "$ref": "#/definitions/docs.ResponseResult" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "404": { + "description": "Not found", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "422": { + "description": "Unprocessable entity", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + } + } + } + }, "/scenarios": { "get": { "security": [ @@ -2889,6 +3400,9 @@ var doc = `{ "database.ComponentConfiguration": { "type": "object", "properties": { + "createdAt": { + "type": "string" + }, "fileIDs": { "description": "Array of file IDs used by the component configuration", "type": "string" @@ -2919,12 +3433,18 @@ var doc = `{ "startParameters": { "description": "Start parameters of Component Configuration as JSON", "type": "string" + }, + "updatedAt": { + "type": "string" } } }, "database.Dashboard": { "type": "object", "properties": { + "createdAt": { + "type": "string" + }, "grid": { "description": "Grid of dashboard", "type": "integer" @@ -2943,12 +3463,18 @@ var doc = `{ "scenarioID": { "description": "ID of scenario to which dashboard belongs", "type": "integer" + }, + "updatedAt": { + "type": "string" } } }, "database.File": { "type": "object", "properties": { + "createdAt": { + "type": "string" + }, "date": { "description": "Last modification time of file", "type": "string" @@ -2956,6 +3482,14 @@ var doc = `{ "id": { "type": "integer" }, + "imageHeight": { + "description": "Height of an image file in pixels (optional)", + "type": "integer" + }, + "imageWidth": { + "description": "Width of an image file in pixels (optional)", + "type": "integer" + }, "name": { "description": "Name of file", "type": "string" @@ -2971,6 +3505,9 @@ var doc = `{ "type": { "description": "Type of file (MIME type)", "type": "string" + }, + "updatedAt": { + "type": "string" } } }, @@ -2985,6 +3522,9 @@ var doc = `{ "description": "Category of IC (simulator, gateway, database, etc.)", "type": "string" }, + "createdAt": { + "type": "string" + }, "description": { "description": "Description of the IC", "type": "string" @@ -3020,6 +3560,9 @@ var doc = `{ "description": "Type of IC (RTDS, VILLASnode, RTDS, etc.)", "type": "string" }, + "updatedAt": { + "type": "string" + }, "uptime": { "description": "Uptime of the IC", "type": "number" @@ -3034,9 +3577,42 @@ var doc = `{ } } }, + "database.Result": { + "type": "object", + "properties": { + "configSnapshots": { + "description": "JSON snapshots of component configurations used to generate results", + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "description": { + "description": "Description of results", + "type": "string" + }, + "id": { + "type": "integer" + }, + "resultFileIDs": { + "description": "File IDs associated with result", + "type": "string" + }, + "scenarioID": { + "description": "ID of Scenario to which result belongs", + "type": "integer" + }, + "updatedAt": { + "type": "string" + } + } + }, "database.Scenario": { "type": "object", "properties": { + "createdAt": { + "type": "string" + }, "id": { "type": "integer" }, @@ -3051,6 +3627,9 @@ var doc = `{ "startParameters": { "description": "Start parameters of scenario as JSON", "type": "string" + }, + "updatedAt": { + "type": "string" } } }, @@ -3061,6 +3640,9 @@ var doc = `{ "description": "ID of Component Configuration", "type": "integer" }, + "createdAt": { + "type": "string" + }, "direction": { "description": "Direction of the signal (in or out)", "type": "string" @@ -3083,6 +3665,9 @@ var doc = `{ "unit": { "description": "Unit of Signal", "type": "string" + }, + "updatedAt": { + "type": "string" } } }, @@ -3093,6 +3678,9 @@ var doc = `{ "description": "Indicating status of user (false means user is inactive and should not be able to login)", "type": "boolean" }, + "createdAt": { + "type": "string" + }, "id": { "type": "integer" }, @@ -3104,6 +3692,9 @@ var doc = `{ "description": "Role of user", "type": "string" }, + "updatedAt": { + "type": "string" + }, "username": { "description": "Username of user", "type": "string" @@ -3113,6 +3704,9 @@ var doc = `{ "database.Widget": { "type": "object", "properties": { + "createdAt": { + "type": "string" + }, "customProperties": { "description": "Custom properties of widget as JSON string", "type": "string" @@ -3152,6 +3746,9 @@ var doc = `{ "description": "Type of widget", "type": "string" }, + "updatedAt": { + "type": "string" + }, "width": { "description": "Width of widget", "type": "integer" @@ -3279,6 +3876,26 @@ var doc = `{ } } }, + "docs.ResponseResult": { + "type": "object", + "properties": { + "result": { + "type": "object", + "$ref": "#/definitions/database.Result" + } + } + }, + "docs.ResponseResults": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/definitions/database.Result" + } + } + } + }, "docs.ResponseScenario": { "type": "object", "properties": { @@ -3462,6 +4079,65 @@ var doc = `{ } } }, + "result.addResultRequest": { + "type": "object", + "properties": { + "result": { + "type": "object", + "$ref": "#/definitions/result.validNewResult" + } + } + }, + "result.updateResultRequest": { + "type": "object", + "properties": { + "result": { + "type": "object", + "$ref": "#/definitions/result.validUpdatedResult" + } + } + }, + "result.validNewResult": { + "type": "object", + "required": [ + "ConfigSnapshots", + "ScenarioID" + ], + "properties": { + "ConfigSnapshots": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "ResultFileIDs": { + "type": "array", + "items": { + "type": "integer" + } + }, + "ScenarioID": { + "type": "integer" + } + } + }, + "result.validUpdatedResult": { + "type": "object", + "properties": { + "configSnapshots": { + "type": "string" + }, + "description": { + "type": "string" + }, + "resultFileIDs": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "scenario.addScenarioRequest": { "type": "object", "properties": { diff --git a/doc/api/responses.go b/doc/api/responses.go index e72b25e..251b4df 100644 --- a/doc/api/responses.go +++ b/doc/api/responses.go @@ -101,3 +101,11 @@ type ResponseFiles struct { type ResponseFile struct { file database.File } + +type ResponseResults struct { + results []database.Result +} + +type ResponseResult struct { + result database.Result +} diff --git a/doc/api/swagger.json b/doc/api/swagger.json index f992ef0..38c05a2 100644 --- a/doc/api/swagger.json +++ b/doc/api/swagger.json @@ -1422,6 +1422,517 @@ } } }, + "/results": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "results" + ], + "summary": "Get all results of scenario", + "operationId": "getResults", + "parameters": [ + { + "type": "integer", + "description": "Scenario ID", + "name": "scenarioID", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "Results which belong to scenario", + "schema": { + "$ref": "#/definitions/docs.ResponseResults" + } + }, + "404": { + "description": "Not found", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "422": { + "description": "Unprocessable entity", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "results" + ], + "summary": "Add a result to a scenario", + "operationId": "addResult", + "parameters": [ + { + "description": "Result to be added incl. ID of Scenario", + "name": "inputResult", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/result.addResultRequest" + } + } + ], + "responses": { + "200": { + "description": "Result that was added", + "schema": { + "$ref": "#/definitions/docs.ResponseResult" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "404": { + "description": "Not found", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "422": { + "description": "Unprocessable entity", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + } + } + } + }, + "/results/{resultID}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "results" + ], + "summary": "Get a Result", + "operationId": "getResult", + "parameters": [ + { + "type": "integer", + "description": "Result ID", + "name": "resultID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Result that was requested", + "schema": { + "$ref": "#/definitions/docs.ResponseResult" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "404": { + "description": "Not found", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "422": { + "description": "Unprocessable entity", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "results" + ], + "summary": "Update a result", + "operationId": "updateResult", + "parameters": [ + { + "description": "Result to be updated", + "name": "inputResult", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/result.updateResultRequest" + } + }, + { + "type": "integer", + "description": "Result ID", + "name": "resultID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Result that was updated", + "schema": { + "$ref": "#/definitions/docs.ResponseResult" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "404": { + "description": "Not found", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "422": { + "description": "Unprocessable entity", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "results" + ], + "summary": "Delete a Result", + "operationId": "deleteResult", + "parameters": [ + { + "type": "integer", + "description": "Result ID", + "name": "resultID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Result that was deleted", + "schema": { + "$ref": "#/definitions/docs.ResponseResult" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "404": { + "description": "Not found", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "422": { + "description": "Unprocessable entity", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + } + } + } + }, + "/results/{resultID}/file": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "consumes": [ + "text/plain", + "text/csv", + "application/gzip", + "application/x-gtar", + "application/x-tar", + "application/x-ustar", + "application/zip", + "application/msexcel", + "application/xml", + "application/x-bag" + ], + "produces": [ + "application/json" + ], + "tags": [ + "results" + ], + "summary": "Upload a result file to the DB and associate it with scenario and result", + "operationId": "addResultFile", + "parameters": [ + { + "type": "file", + "description": "File to be uploaded", + "name": "inputFile", + "in": "formData", + "required": true + }, + { + "type": "integer", + "description": "Result ID", + "name": "resultID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Result that was updated", + "schema": { + "$ref": "#/definitions/docs.ResponseResult" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "404": { + "description": "Not found", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "422": { + "description": "Unprocessable entity", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + } + } + } + }, + "/results/{resultID}/file/{fileID}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "text/plain", + "text/csv", + "application/gzip", + "application/x-gtar", + "application/x-tar", + "application/x-ustar", + "application/zip", + "application/msexcel", + "application/xml", + "application/x-bag" + ], + "tags": [ + "results" + ], + "summary": "Download a result file", + "operationId": "getResultFile", + "parameters": [ + { + "type": "integer", + "description": "Result ID", + "name": "resultID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "ID of the file to download", + "name": "fileID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "File that was requested", + "schema": { + "$ref": "#/definitions/docs.ResponseFile" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "404": { + "description": "Not found", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "422": { + "description": "Unprocessable entity", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "results" + ], + "summary": "Delete a result file", + "operationId": "deleteResultFile", + "parameters": [ + { + "type": "integer", + "description": "Result ID", + "name": "resultID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "ID of the file to delete", + "name": "fileID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Result for which file was deleted", + "schema": { + "$ref": "#/definitions/docs.ResponseResult" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "404": { + "description": "Not found", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "422": { + "description": "Unprocessable entity", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/docs.ResponseError" + } + } + } + } + }, "/scenarios": { "get": { "security": [ @@ -2872,6 +3383,9 @@ "database.ComponentConfiguration": { "type": "object", "properties": { + "createdAt": { + "type": "string" + }, "fileIDs": { "description": "Array of file IDs used by the component configuration", "type": "string" @@ -2902,12 +3416,18 @@ "startParameters": { "description": "Start parameters of Component Configuration as JSON", "type": "string" + }, + "updatedAt": { + "type": "string" } } }, "database.Dashboard": { "type": "object", "properties": { + "createdAt": { + "type": "string" + }, "grid": { "description": "Grid of dashboard", "type": "integer" @@ -2926,12 +3446,18 @@ "scenarioID": { "description": "ID of scenario to which dashboard belongs", "type": "integer" + }, + "updatedAt": { + "type": "string" } } }, "database.File": { "type": "object", "properties": { + "createdAt": { + "type": "string" + }, "date": { "description": "Last modification time of file", "type": "string" @@ -2939,6 +3465,14 @@ "id": { "type": "integer" }, + "imageHeight": { + "description": "Height of an image file in pixels (optional)", + "type": "integer" + }, + "imageWidth": { + "description": "Width of an image file in pixels (optional)", + "type": "integer" + }, "name": { "description": "Name of file", "type": "string" @@ -2954,6 +3488,9 @@ "type": { "description": "Type of file (MIME type)", "type": "string" + }, + "updatedAt": { + "type": "string" } } }, @@ -2968,6 +3505,9 @@ "description": "Category of IC (simulator, gateway, database, etc.)", "type": "string" }, + "createdAt": { + "type": "string" + }, "description": { "description": "Description of the IC", "type": "string" @@ -3003,6 +3543,9 @@ "description": "Type of IC (RTDS, VILLASnode, RTDS, etc.)", "type": "string" }, + "updatedAt": { + "type": "string" + }, "uptime": { "description": "Uptime of the IC", "type": "number" @@ -3017,9 +3560,42 @@ } } }, + "database.Result": { + "type": "object", + "properties": { + "configSnapshots": { + "description": "JSON snapshots of component configurations used to generate results", + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "description": { + "description": "Description of results", + "type": "string" + }, + "id": { + "type": "integer" + }, + "resultFileIDs": { + "description": "File IDs associated with result", + "type": "string" + }, + "scenarioID": { + "description": "ID of Scenario to which result belongs", + "type": "integer" + }, + "updatedAt": { + "type": "string" + } + } + }, "database.Scenario": { "type": "object", "properties": { + "createdAt": { + "type": "string" + }, "id": { "type": "integer" }, @@ -3034,6 +3610,9 @@ "startParameters": { "description": "Start parameters of scenario as JSON", "type": "string" + }, + "updatedAt": { + "type": "string" } } }, @@ -3044,6 +3623,9 @@ "description": "ID of Component Configuration", "type": "integer" }, + "createdAt": { + "type": "string" + }, "direction": { "description": "Direction of the signal (in or out)", "type": "string" @@ -3066,6 +3648,9 @@ "unit": { "description": "Unit of Signal", "type": "string" + }, + "updatedAt": { + "type": "string" } } }, @@ -3076,6 +3661,9 @@ "description": "Indicating status of user (false means user is inactive and should not be able to login)", "type": "boolean" }, + "createdAt": { + "type": "string" + }, "id": { "type": "integer" }, @@ -3087,6 +3675,9 @@ "description": "Role of user", "type": "string" }, + "updatedAt": { + "type": "string" + }, "username": { "description": "Username of user", "type": "string" @@ -3096,6 +3687,9 @@ "database.Widget": { "type": "object", "properties": { + "createdAt": { + "type": "string" + }, "customProperties": { "description": "Custom properties of widget as JSON string", "type": "string" @@ -3135,6 +3729,9 @@ "description": "Type of widget", "type": "string" }, + "updatedAt": { + "type": "string" + }, "width": { "description": "Width of widget", "type": "integer" @@ -3262,6 +3859,26 @@ } } }, + "docs.ResponseResult": { + "type": "object", + "properties": { + "result": { + "type": "object", + "$ref": "#/definitions/database.Result" + } + } + }, + "docs.ResponseResults": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/definitions/database.Result" + } + } + } + }, "docs.ResponseScenario": { "type": "object", "properties": { @@ -3445,6 +4062,65 @@ } } }, + "result.addResultRequest": { + "type": "object", + "properties": { + "result": { + "type": "object", + "$ref": "#/definitions/result.validNewResult" + } + } + }, + "result.updateResultRequest": { + "type": "object", + "properties": { + "result": { + "type": "object", + "$ref": "#/definitions/result.validUpdatedResult" + } + } + }, + "result.validNewResult": { + "type": "object", + "required": [ + "ConfigSnapshots", + "ScenarioID" + ], + "properties": { + "ConfigSnapshots": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "ResultFileIDs": { + "type": "array", + "items": { + "type": "integer" + } + }, + "ScenarioID": { + "type": "integer" + } + } + }, + "result.validUpdatedResult": { + "type": "object", + "properties": { + "configSnapshots": { + "type": "string" + }, + "description": { + "type": "string" + }, + "resultFileIDs": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "scenario.addScenarioRequest": { "type": "object", "properties": { diff --git a/doc/api/swagger.yaml b/doc/api/swagger.yaml index 7cb52c3..74ceced 100644 --- a/doc/api/swagger.yaml +++ b/doc/api/swagger.yaml @@ -83,6 +83,8 @@ definitions: type: object database.ComponentConfiguration: properties: + createdAt: + type: string fileIDs: description: Array of file IDs used by the component configuration type: string @@ -106,9 +108,13 @@ definitions: startParameters: description: Start parameters of Component Configuration as JSON type: string + updatedAt: + type: string type: object database.Dashboard: properties: + createdAt: + type: string grid: description: Grid of dashboard type: integer @@ -123,14 +129,24 @@ definitions: scenarioID: description: ID of scenario to which dashboard belongs type: integer + updatedAt: + type: string type: object database.File: properties: + createdAt: + type: string date: description: Last modification time of file type: string id: type: integer + imageHeight: + description: Height of an image file in pixels (optional) + type: integer + imageWidth: + description: Width of an image file in pixels (optional) + type: integer name: description: Name of file type: string @@ -143,6 +159,8 @@ definitions: type: description: Type of file (MIME type) type: string + updatedAt: + type: string type: object database.InfrastructureComponent: properties: @@ -152,6 +170,8 @@ definitions: category: description: Category of IC (simulator, gateway, database, etc.) type: string + createdAt: + type: string description: description: Description of the IC type: string @@ -178,6 +198,8 @@ definitions: type: description: Type of IC (RTDS, VILLASnode, RTDS, etc.) type: string + updatedAt: + type: string uptime: description: Uptime of the IC type: number @@ -188,8 +210,31 @@ definitions: description: WebsocketURL if the IC type: string type: object + database.Result: + properties: + configSnapshots: + description: JSON snapshots of component configurations used to generate results + type: string + createdAt: + type: string + description: + description: Description of results + type: string + id: + type: integer + resultFileIDs: + description: File IDs associated with result + type: string + scenarioID: + description: ID of Scenario to which result belongs + type: integer + updatedAt: + type: string + type: object database.Scenario: properties: + createdAt: + type: string id: type: integer name: @@ -201,12 +246,16 @@ definitions: startParameters: description: Start parameters of scenario as JSON type: string + updatedAt: + type: string type: object database.Signal: properties: configID: description: ID of Component Configuration type: integer + createdAt: + type: string direction: description: Direction of the signal (in or out) type: string @@ -224,6 +273,8 @@ definitions: unit: description: Unit of Signal type: string + updatedAt: + type: string type: object database.User: properties: @@ -231,6 +282,8 @@ definitions: description: Indicating status of user (false means user is inactive and should not be able to login) type: boolean + createdAt: + type: string id: type: integer mail: @@ -239,12 +292,16 @@ definitions: role: description: Role of user type: string + updatedAt: + type: string username: description: Username of user type: string type: object database.Widget: properties: + createdAt: + type: string customProperties: description: Custom properties of widget as JSON string type: string @@ -274,6 +331,8 @@ definitions: type: description: Type of widget type: string + updatedAt: + type: string width: description: Width of widget type: integer @@ -358,6 +417,19 @@ definitions: $ref: '#/definitions/database.InfrastructureComponent' type: array type: object + docs.ResponseResult: + properties: + result: + $ref: '#/definitions/database.Result' + type: object + type: object + docs.ResponseResults: + properties: + results: + items: + $ref: '#/definitions/database.Result' + type: array + type: object docs.ResponseScenario: properties: scenario: @@ -479,6 +551,45 @@ definitions: WebsocketURL: type: string type: object + result.addResultRequest: + properties: + result: + $ref: '#/definitions/result.validNewResult' + type: object + type: object + result.updateResultRequest: + properties: + result: + $ref: '#/definitions/result.validUpdatedResult' + type: object + type: object + result.validNewResult: + properties: + ConfigSnapshots: + type: string + Description: + type: string + ResultFileIDs: + items: + type: integer + type: array + ScenarioID: + type: integer + required: + - ConfigSnapshots + - ScenarioID + type: object + result.validUpdatedResult: + properties: + configSnapshots: + type: string + description: + type: string + resultFileIDs: + items: + type: integer + type: array + type: object scenario.addScenarioRequest: properties: scenario: @@ -1616,6 +1727,342 @@ paths: summary: Prometheus metrics endpoint tags: - metrics + /results: + get: + operationId: getResults + parameters: + - description: Scenario ID + in: query + name: scenarioID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Results which belong to scenario + schema: + $ref: '#/definitions/docs.ResponseResults' + "404": + description: Not found + schema: + $ref: '#/definitions/docs.ResponseError' + "422": + description: Unprocessable entity + schema: + $ref: '#/definitions/docs.ResponseError' + "500": + description: Internal server error + schema: + $ref: '#/definitions/docs.ResponseError' + security: + - Bearer: [] + summary: Get all results of scenario + tags: + - results + post: + consumes: + - application/json + operationId: addResult + parameters: + - description: Result to be added incl. ID of Scenario + in: body + name: inputResult + required: true + schema: + $ref: '#/definitions/result.addResultRequest' + type: object + produces: + - application/json + responses: + "200": + description: Result that was added + schema: + $ref: '#/definitions/docs.ResponseResult' + "400": + description: Bad request + schema: + $ref: '#/definitions/docs.ResponseError' + "404": + description: Not found + schema: + $ref: '#/definitions/docs.ResponseError' + "422": + description: Unprocessable entity + schema: + $ref: '#/definitions/docs.ResponseError' + "500": + description: Internal server error + schema: + $ref: '#/definitions/docs.ResponseError' + security: + - Bearer: [] + summary: Add a result to a scenario + tags: + - results + /results/{resultID}: + delete: + operationId: deleteResult + parameters: + - description: Result ID + in: path + name: resultID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Result that was deleted + schema: + $ref: '#/definitions/docs.ResponseResult' + "400": + description: Bad request + schema: + $ref: '#/definitions/docs.ResponseError' + "404": + description: Not found + schema: + $ref: '#/definitions/docs.ResponseError' + "422": + description: Unprocessable entity + schema: + $ref: '#/definitions/docs.ResponseError' + "500": + description: Internal server error + schema: + $ref: '#/definitions/docs.ResponseError' + security: + - Bearer: [] + summary: Delete a Result + tags: + - results + get: + operationId: getResult + parameters: + - description: Result ID + in: path + name: resultID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Result that was requested + schema: + $ref: '#/definitions/docs.ResponseResult' + "400": + description: Bad request + schema: + $ref: '#/definitions/docs.ResponseError' + "404": + description: Not found + schema: + $ref: '#/definitions/docs.ResponseError' + "422": + description: Unprocessable entity + schema: + $ref: '#/definitions/docs.ResponseError' + "500": + description: Internal server error + schema: + $ref: '#/definitions/docs.ResponseError' + security: + - Bearer: [] + summary: Get a Result + tags: + - results + put: + consumes: + - application/json + operationId: updateResult + parameters: + - description: Result to be updated + in: body + name: inputResult + required: true + schema: + $ref: '#/definitions/result.updateResultRequest' + type: object + - description: Result ID + in: path + name: resultID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Result that was updated + schema: + $ref: '#/definitions/docs.ResponseResult' + "400": + description: Bad request + schema: + $ref: '#/definitions/docs.ResponseError' + "404": + description: Not found + schema: + $ref: '#/definitions/docs.ResponseError' + "422": + description: Unprocessable entity + schema: + $ref: '#/definitions/docs.ResponseError' + "500": + description: Internal server error + schema: + $ref: '#/definitions/docs.ResponseError' + security: + - Bearer: [] + summary: Update a result + tags: + - results + /results/{resultID}/file: + post: + consumes: + - text/plain + - text/csv + - application/gzip + - application/x-gtar + - application/x-tar + - application/x-ustar + - application/zip + - application/msexcel + - application/xml + - application/x-bag + operationId: addResultFile + parameters: + - description: File to be uploaded + in: formData + name: inputFile + required: true + type: file + - description: Result ID + in: path + name: resultID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Result that was updated + schema: + $ref: '#/definitions/docs.ResponseResult' + "400": + description: Bad request + schema: + $ref: '#/definitions/docs.ResponseError' + "404": + description: Not found + schema: + $ref: '#/definitions/docs.ResponseError' + "422": + description: Unprocessable entity + schema: + $ref: '#/definitions/docs.ResponseError' + "500": + description: Internal server error + schema: + $ref: '#/definitions/docs.ResponseError' + security: + - Bearer: [] + summary: Upload a result file to the DB and associate it with scenario and result + tags: + - results + /results/{resultID}/file/{fileID}: + delete: + operationId: deleteResultFile + parameters: + - description: Result ID + in: path + name: resultID + required: true + type: integer + - description: ID of the file to delete + in: path + name: fileID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Result for which file was deleted + schema: + $ref: '#/definitions/docs.ResponseResult' + "400": + description: Bad request + schema: + $ref: '#/definitions/docs.ResponseError' + "404": + description: Not found + schema: + $ref: '#/definitions/docs.ResponseError' + "422": + description: Unprocessable entity + schema: + $ref: '#/definitions/docs.ResponseError' + "500": + description: Internal server error + schema: + $ref: '#/definitions/docs.ResponseError' + security: + - Bearer: [] + summary: Delete a result file + tags: + - results + get: + operationId: getResultFile + parameters: + - description: Result ID + in: path + name: resultID + required: true + type: integer + - description: ID of the file to download + in: path + name: fileID + required: true + type: integer + produces: + - text/plain + - text/csv + - application/gzip + - application/x-gtar + - application/x-tar + - application/x-ustar + - application/zip + - application/msexcel + - application/xml + - application/x-bag + responses: + "200": + description: File that was requested + schema: + $ref: '#/definitions/docs.ResponseFile' + "400": + description: Bad request + schema: + $ref: '#/definitions/docs.ResponseError' + "404": + description: Not found + schema: + $ref: '#/definitions/docs.ResponseError' + "422": + description: Unprocessable entity + schema: + $ref: '#/definitions/docs.ResponseError' + "500": + description: Internal server error + schema: + $ref: '#/definitions/docs.ResponseError' + security: + - Bearer: [] + summary: Download a result file + tags: + - results /scenarios: get: operationId: getScenarios diff --git a/routes/dashboard/dashboard_methods.go b/routes/dashboard/dashboard_methods.go index 561ab92..2a9b438 100644 --- a/routes/dashboard/dashboard_methods.go +++ b/routes/dashboard/dashboard_methods.go @@ -88,8 +88,10 @@ func (d *Dashboard) delete() error { } // remove association between Dashboard and Scenario - // Dashboard itself is not deleted from DB, it remains as "dangling" err = db.Model(&sim).Association("Dashboards").Delete(d).Error + // Dashboard itself is not deleted from DB, it remains as "dangling" + // TODO: delete dashboard and associated widgets + return err } diff --git a/routes/register.go b/routes/register.go index 3cc1078..10b86a1 100644 --- a/routes/register.go +++ b/routes/register.go @@ -39,6 +39,7 @@ import ( "git.rwth-aachen.de/acs/public/villas/web-backend-go/routes/healthz" infrastructure_component "git.rwth-aachen.de/acs/public/villas/web-backend-go/routes/infrastructure-component" "git.rwth-aachen.de/acs/public/villas/web-backend-go/routes/metrics" + "git.rwth-aachen.de/acs/public/villas/web-backend-go/routes/result" "git.rwth-aachen.de/acs/public/villas/web-backend-go/routes/scenario" "git.rwth-aachen.de/acs/public/villas/web-backend-go/routes/signal" "git.rwth-aachen.de/acs/public/villas/web-backend-go/routes/user" @@ -68,6 +69,7 @@ func RegisterEndpoints(router *gin.Engine, api *gin.RouterGroup) { file.RegisterFileEndpoints(api.Group("/files")) user.RegisterUserEndpoints(api.Group("/users")) infrastructure_component.RegisterICEndpoints(api.Group("/ic")) + result.RegisterResultEndpoints(api.Group("/results")) router.GET("swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) diff --git a/routes/result/result_endpoints.go b/routes/result/result_endpoints.go new file mode 100644 index 0000000..adc532a --- /dev/null +++ b/routes/result/result_endpoints.go @@ -0,0 +1,281 @@ +package result + +import ( + "git.rwth-aachen.de/acs/public/villas/web-backend-go/database" + "git.rwth-aachen.de/acs/public/villas/web-backend-go/helper" + "git.rwth-aachen.de/acs/public/villas/web-backend-go/routes/scenario" + "github.com/gin-gonic/gin" + "net/http" +) + +func RegisterResultEndpoints(r *gin.RouterGroup) { + r.GET("", getResults) + r.POST("", addResult) + r.PUT("/:resultID", updateResult) + r.GET("/:resultID", getResult) + r.DELETE("/:scenarioID", deleteResult) + r.POST("/:resultID/file", addResultFile) + r.GET("/:resultID/file/:fileID", getResultFile) + r.DELETE("/:resultID/file/:fileID", deleteResultFile) +} + +// getResults godoc +// @Summary Get all results of scenario +// @ID getResults +// @Produce json +// @Tags results +// @Success 200 {object} docs.ResponseResults "Results which belong to scenario" +// @Failure 404 {object} docs.ResponseError "Not found" +// @Failure 422 {object} docs.ResponseError "Unprocessable entity" +// @Failure 500 {object} docs.ResponseError "Internal server error" +// @Param scenarioID query int true "Scenario ID" +// @Router /results [get] +// @Security Bearer +func getResults(c *gin.Context) { + + ok, scenario := scenario.CheckPermissions(c, database.Read, "query", -1) + if !ok { + return + } + + db := database.GetDB() + var result []database.Result + err := db.Order("ID asc").Model(scenario).Related(&result, "Results").Error + if !helper.DBError(c, err) { + c.JSON(http.StatusOK, gin.H{"result": result}) + } +} + +// addResult godoc +// @Summary Add a result to a scenario +// @ID addResult +// @Accept json +// @Produce json +// @Tags results +// @Success 200 {object} docs.ResponseResult "Result that was added" +// @Failure 400 {object} docs.ResponseError "Bad request" +// @Failure 404 {object} docs.ResponseError "Not found" +// @Failure 422 {object} docs.ResponseError "Unprocessable entity" +// @Failure 500 {object} docs.ResponseError "Internal server error" +// @Param inputResult body result.addResultRequest true "Result to be added incl. ID of Scenario" +// @Router /results [post] +// @Security Bearer +func addResult(c *gin.Context) { + + // bind request to JSON + var req addResultRequest + if err := c.ShouldBindJSON(&req); err != nil { + helper.BadRequestError(c, err.Error()) + return + } + + // Validate the request + if err := req.validate(); err != nil { + helper.UnprocessableEntityError(c, err.Error()) + return + } + + // Create the new result from the request + newResult := req.createResult() + + // Check if user is allowed to modify scenario specified in request + ok, _ := scenario.CheckPermissions(c, database.Update, "body", int(newResult.ScenarioID)) + if !ok { + return + } + + // add result to DB and add association to scenario + err := newResult.addToScenario() + if !helper.DBError(c, err) { + c.JSON(http.StatusOK, gin.H{"result": newResult.Result}) + } + +} + +// updateResult godoc +// @Summary Update a result +// @ID updateResult +// @Tags results +// @Accept json +// @Produce json +// @Success 200 {object} docs.ResponseResult "Result that was updated" +// @Failure 400 {object} docs.ResponseError "Bad request" +// @Failure 404 {object} docs.ResponseError "Not found" +// @Failure 422 {object} docs.ResponseError "Unprocessable entity" +// @Failure 500 {object} docs.ResponseError "Internal server error" +// @Param inputResult body result.updateResultRequest true "Result to be updated" +// @Param resultID path int true "Result ID" +// @Router /results/{resultID} [put] +// @Security Bearer +func updateResult(c *gin.Context) { + + ok, oldResult := CheckPermissions(c, database.Update, "path", -1) + if !ok { + return + } + + var req updateResultRequest + if err := c.ShouldBindJSON(&req); err != nil { + helper.BadRequestError(c, err.Error()) + return + } + + // Validate the request + if err := req.Result.validate(); err != nil { + helper.BadRequestError(c, err.Error()) + return + } + // Create the updatedResult from oldResult + updatedResult := req.updatedResult(oldResult) + + // update the Result in the DB + err := oldResult.update(updatedResult) + if !helper.DBError(c, err) { + c.JSON(http.StatusOK, gin.H{"result": updatedResult.Result}) + } + +} + +// getResult godoc +// @Summary Get a Result +// @ID getResult +// @Tags results +// @Produce json +// @Success 200 {object} docs.ResponseResult "Result that was requested" +// @Failure 400 {object} docs.ResponseError "Bad request" +// @Failure 404 {object} docs.ResponseError "Not found" +// @Failure 422 {object} docs.ResponseError "Unprocessable entity" +// @Failure 500 {object} docs.ResponseError "Internal server error" +// @Param resultID path int true "Result ID" +// @Router /results/{resultID} [get] +// @Security Bearer +func getResult(c *gin.Context) { + + ok, result := CheckPermissions(c, database.Read, "path", -1) + if !ok { + return + } + + c.JSON(http.StatusOK, gin.H{"result": result.Result}) +} + +// deleteResult godoc +// @Summary Delete a Result +// @ID deleteResult +// @Tags results +// @Produce json +// @Success 200 {object} docs.ResponseResult "Result that was deleted" +// @Failure 400 {object} docs.ResponseError "Bad request" +// @Failure 404 {object} docs.ResponseError "Not found" +// @Failure 422 {object} docs.ResponseError "Unprocessable entity" +// @Failure 500 {object} docs.ResponseError "Internal server error" +// @Param resultID path int true "Result ID" +// @Router /results/{resultID} [delete] +// @Security Bearer +func deleteResult(c *gin.Context) { + ok, result := CheckPermissions(c, database.Delete, "path", -1) + if !ok { + return + } + + err := result.delete() + if !helper.DBError(c, err) { + c.JSON(http.StatusOK, gin.H{"result": result.Result}) + } + +} + +// addResultFile godoc +// @Summary Upload a result file to the DB and associate it with scenario and result +// @ID addResultFile +// @Tags results +// @Accept text/plain +// @Accept text/csv +// @Accept application/gzip +// @Accept application/x-gtar +// @Accept application/x-tar +// @Accept application/x-ustar +// @Accept application/zip +// @Accept application/msexcel +// @Accept application/xml +// @Accept application/x-bag +// @Produce json +// @Success 200 {object} docs.ResponseResult "Result that was updated" +// @Failure 400 {object} docs.ResponseError "Bad request" +// @Failure 404 {object} docs.ResponseError "Not found" +// @Failure 422 {object} docs.ResponseError "Unprocessable entity" +// @Failure 500 {object} docs.ResponseError "Internal server error" +// @Param inputFile formData file true "File to be uploaded" +// @Param resultID path int true "Result ID" +// @Router /results/{resultID}/file [post] +// @Security Bearer +func addResultFile(c *gin.Context) { + ok, _ := CheckPermissions(c, database.Update, "path", -1) + if !ok { + return + } + + // TODO check permissions of scenario first (file will be added to scenario) + + // TODO add file to DB, associate with scenario and add file ID to result + +} + +// getResultFile godoc +// @Summary Download a result file +// @ID getResultFile +// @Tags results +// @Produce text/plain +// @Produce text/csv +// @Produce application/gzip +// @Produce application/x-gtar +// @Produce application/x-tar +// @Produce application/x-ustar +// @Produce application/zip +// @Produce application/msexcel +// @Produce application/xml +// @Produce application/x-bag +// @Success 200 {object} docs.ResponseFile "File that was requested" +// @Failure 400 {object} docs.ResponseError "Bad request" +// @Failure 404 {object} docs.ResponseError "Not found" +// @Failure 422 {object} docs.ResponseError "Unprocessable entity" +// @Failure 500 {object} docs.ResponseError "Internal server error" +// @Param resultID path int true "Result ID" +// @Param fileID path int true "ID of the file to download" +// @Router /results/{resultID}/file/{fileID} [get] +// @Security Bearer +func getResultFile(c *gin.Context) { + + // check access + ok, _ := CheckPermissions(c, database.Read, "path", -1) + if !ok { + return + } + + // TODO download result file +} + +// deleteResultFile godoc +// @Summary Delete a result file +// @ID deleteResultFile +// @Tags results +// @Produce json +// @Success 200 {object} docs.ResponseResult "Result for which file was deleted" +// @Failure 400 {object} docs.ResponseError "Bad request" +// @Failure 404 {object} docs.ResponseError "Not found" +// @Failure 422 {object} docs.ResponseError "Unprocessable entity" +// @Failure 500 {object} docs.ResponseError "Internal server error" +// @Param resultID path int true "Result ID" +// @Param fileID path int true "ID of the file to delete" +// @Router /results/{resultID}/file/{fileID} [delete] +// @Security Bearer +func deleteResultFile(c *gin.Context) { + // TODO check access to scenario (file deletion) first + + // check access + ok, _ := CheckPermissions(c, database.Update, "path", -1) + if !ok { + return + } + +} diff --git a/routes/result/result_methods.go b/routes/result/result_methods.go new file mode 100644 index 0000000..48fb528 --- /dev/null +++ b/routes/result/result_methods.go @@ -0,0 +1,75 @@ +package result + +import ( + "git.rwth-aachen.de/acs/public/villas/web-backend-go/database" + "git.rwth-aachen.de/acs/public/villas/web-backend-go/routes/scenario" +) + +type Result struct { + database.Result +} + +func (r *Result) save() error { + db := database.GetDB() + err := db.Create(r).Error + return err +} + +func (r *Result) ByID(id uint) error { + db := database.GetDB() + err := db.Find(r, id).Error + if err != nil { + return err + } + return nil +} + +func (r *Result) addToScenario() error { + db := database.GetDB() + var sco scenario.Scenario + err := sco.ByID(r.ScenarioID) + if err != nil { + return err + } + + // save result to DB + err = r.save() + if err != nil { + return err + } + + // associate result with scenario + err = db.Model(&sco).Association("Results").Append(r).Error + + return err +} + +func (r *Result) update(modifiedResult Result) error { + + db := database.GetDB() + + err := db.Model(r).Updates(map[string]interface{}{ + "Description": modifiedResult.Description, + "ConfigSnapshots": modifiedResult.ConfigSnapshots, + "ResultFileIDs": modifiedResult.ResultFileIDs, + }).Error + + return err +} + +func (r *Result) delete() error { + + db := database.GetDB() + var sco scenario.Scenario + err := sco.ByID(r.ScenarioID) + if err != nil { + return err + } + + // remove association between Result and Scenario + err = db.Model(&sco).Association("Results").Delete(r).Error + + // TODO delete Result + files (if any) + + return err +} diff --git a/routes/result/result_middleware.go b/routes/result/result_middleware.go new file mode 100644 index 0000000..70a199b --- /dev/null +++ b/routes/result/result_middleware.go @@ -0,0 +1,37 @@ +package result + +import ( + "fmt" + "git.rwth-aachen.de/acs/public/villas/web-backend-go/database" + "git.rwth-aachen.de/acs/public/villas/web-backend-go/helper" + "git.rwth-aachen.de/acs/public/villas/web-backend-go/routes/scenario" + "github.com/gin-gonic/gin" +) + +func CheckPermissions(c *gin.Context, operation database.CRUD, resultIDSource string, resultIDBody int) (bool, Result) { + + var result Result + + err := database.ValidateRole(c, database.ModelResult, operation) + if err != nil { + helper.UnprocessableEntityError(c, fmt.Sprintf("Access denied (role validation failed): %v", err.Error())) + return false, result + } + + resultID, err := helper.GetIDOfElement(c, "resultID", resultIDSource, resultIDBody) + if err != nil { + return false, result + } + + err = result.ByID(uint(resultID)) + if helper.DBError(c, err) { + return false, result + } + + ok, _ := scenario.CheckPermissions(c, operation, "body", int(result.ScenarioID)) + if !ok { + return false, result + } + + return true, result +} diff --git a/routes/result/result_test.go b/routes/result/result_test.go new file mode 100644 index 0000000..2705049 --- /dev/null +++ b/routes/result/result_test.go @@ -0,0 +1 @@ +package result diff --git a/routes/result/result_validators.go b/routes/result/result_validators.go new file mode 100644 index 0000000..1f4da71 --- /dev/null +++ b/routes/result/result_validators.go @@ -0,0 +1,63 @@ +package result + +import ( + "github.com/jinzhu/gorm/dialects/postgres" + "gopkg.in/go-playground/validator.v9" +) + +var validate *validator.Validate + +type validNewResult struct { + Description string `form:"Description" validate:"omitempty"` + ResultFileIDs []int64 `form:"ResultFileIDs" validate:"omitempty"` + ConfigSnapshots []postgres.Jsonb `form:"ConfigSnapshots" validate:"required"` + ScenarioID uint `form:"ScenarioID" validate:"required"` +} + +type validUpdatedResult struct { + Description string `form:"Description" validate:"omitempty" json:"description"` + ResultFileIDs []int64 `form:"ResultFileIDs" validate:"omitempty" json:"resultFileIDs"` + ConfigSnapshots []postgres.Jsonb `form:"ConfigSnapshots" validate:"omitempty" json:"configSnapshots"` +} + +type addResultRequest struct { + Result validNewResult `json:"result"` +} + +type updateResultRequest struct { + Result validUpdatedResult `json:"result"` +} + +func (r *addResultRequest) validate() error { + validate = validator.New() + errs := validate.Struct(r) + return errs +} + +func (r *validUpdatedResult) validate() error { + validate = validator.New() + errs := validate.Struct(r) + return errs +} + +func (r *addResultRequest) createResult() Result { + var s Result + + s.Description = r.Result.Description + s.ConfigSnapshots = r.Result.ConfigSnapshots + s.ResultFileIDs = r.Result.ResultFileIDs + s.ScenarioID = r.Result.ScenarioID + + return s +} + +func (r *updateResultRequest) updatedResult(oldResult Result) Result { + // Use the old Result as a basis for the updated Result `s` + s := oldResult + + s.Result.Description = r.Result.Description + s.ConfigSnapshots = r.Result.ConfigSnapshots + s.ResultFileIDs = r.Result.ResultFileIDs + + return s +} From d9175d9432244d6d66c135540065068127d310e1 Mon Sep 17 00:00:00 2001 From: Sonja Happ Date: Thu, 19 Nov 2020 17:29:51 +0100 Subject: [PATCH 02/11] fix a typo in result endpoint registration --- routes/result/result_endpoints.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/result/result_endpoints.go b/routes/result/result_endpoints.go index adc532a..ccb734b 100644 --- a/routes/result/result_endpoints.go +++ b/routes/result/result_endpoints.go @@ -13,7 +13,7 @@ func RegisterResultEndpoints(r *gin.RouterGroup) { r.POST("", addResult) r.PUT("/:resultID", updateResult) r.GET("/:resultID", getResult) - r.DELETE("/:scenarioID", deleteResult) + r.DELETE("/:resultID", deleteResult) r.POST("/:resultID/file", addResultFile) r.GET("/:resultID/file/:fileID", getResultFile) r.DELETE("/:resultID/file/:fileID", deleteResultFile) From e1b931168606c39a511f30f5cc37e957cac34d64 Mon Sep 17 00:00:00 2001 From: Sonja Happ Date: Fri, 20 Nov 2020 09:19:09 +0100 Subject: [PATCH 03/11] CI: reduce number of jobs in test stage to one job that runs all go tests --- .gitlab-ci.yml | 73 ++++---------------------------------------------- 1 file changed, 5 insertions(+), 68 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c73fbdd..321f16c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -22,11 +22,10 @@ stages: - test - deploy - # Stage: build ############################################################################## -build:backend: +build: stage: build image: ${GO_IMAGE} script: @@ -42,7 +41,7 @@ build:backend: # Stage: test ############################################################################## -test:gotest: +test: stage: test image: ${GO_IMAGE} variables: @@ -62,75 +61,13 @@ test:gotest: -coverprofile ./testcover.txt - go tool cover -func=testcover.txt dependencies: - - build:backend - -test:database: - stage: test - image: ${GO_IMAGE} - variables: - TEST_FOLDER: database - MODE: test - DB_NAME: ${POSTGRES_DB} - DB_HOST: ${POSTGRES_HOST} - DB_USER: ${POSTGRES_USER} - DB_PASS: ${POSTGRES_PASSWORD} - script: - - go mod tidy - - cd ${TEST_FOLDER} - - go test -v - dependencies: - - build:backend - -test:scenario: - extends: test:database - variables: - TEST_FOLDER: routes/scenario - -test:component-configuration: - extends: test:database - variables: - TEST_FOLDER: routes/component-configuration - -test:signal: - extends: test:database - variables: - TEST_FOLDER: routes/signal - -test:dashboard: - extends: test:database - variables: - TEST_FOLDER: routes/dashboard - -test:widget: - extends: test:database - variables: - TEST_FOLDER: routes/widget - -test:infrastructure-component: - extends: test:database - variables: - TEST_FOLDER: routes/infrastructure-component - -test:file: - extends: test:database - variables: - TEST_FOLDER: routes/file - -test:user: - extends: test:database - variables: - TEST_FOLDER: routes/user - -test:healthz: - extends: test:database - variables: - TEST_FOLDER: routes/healthz + - build # Stage: deploy ############################################################################## -deploy:image: +deploy: stage: deploy image: name: gcr.io/kaniko-project/executor:debug @@ -146,4 +83,4 @@ deploy:image: --cache=true --cache-ttl=12h dependencies: - - test:gotest + - test From 70a483546d022b6333439f433aa5dee39b064794 Mon Sep 17 00:00:00 2001 From: Sonja Happ Date: Fri, 20 Nov 2020 09:38:55 +0100 Subject: [PATCH 04/11] fix scenario test, createdAt and updatedAt are now contained in API responses --- routes/scenario/scenario_test.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/routes/scenario/scenario_test.go b/routes/scenario/scenario_test.go index f64a30f..3b8e609 100644 --- a/routes/scenario/scenario_test.go +++ b/routes/scenario/scenario_test.go @@ -500,7 +500,12 @@ func TestAddUserToScenario(t *testing.T) { assert.Equalf(t, 200, code, "Response body: \n%v\n", resp) // Compare resp to userB - err = helper.CompareResponse(resp, helper.KeyModels{"user": helper.UserB}) + userB := UserRequest{ + Username: helper.UserB.Username, + Mail: helper.UserB.Mail, + Role: helper.UserB.Role, + } + err = helper.CompareResponse(resp, helper.KeyModels{"user": userB}) assert.NoError(t, err) // Count AGAIN the number of all the users returned for newScenario @@ -663,8 +668,13 @@ func TestRemoveUserFromScenario(t *testing.T) { assert.NoError(t, err) assert.Equalf(t, 200, code, "Response body: \n%v\n", resp) - // Compare DELETE's response with UserB - err = helper.CompareResponse(resp, helper.KeyModels{"user": helper.UserC}) + // Compare DELETE's response with UserC's data + userC := UserRequest{ + Username: helper.UserC.Username, + Mail: helper.UserC.Mail, + Role: helper.UserC.Role, + } + err = helper.CompareResponse(resp, helper.KeyModels{"user": userC}) assert.NoError(t, err) // Count AGAIN the number of all the users returned for newScenario From 77d02ec7a762bd24b1f65e74df872422c2d75434 Mon Sep 17 00:00:00 2001 From: Sonja Happ Date: Fri, 20 Nov 2020 13:49:32 +0100 Subject: [PATCH 05/11] Delete signals, dashboards, widgets and component configs + associated elements from DB upon DELETE (no safe-delete anymore) --- .../component-configuration/config_methods.go | 27 ++++++++++++++++++- routes/dashboard/dashboard_methods.go | 20 +++++++++++--- routes/signal/signal_methods.go | 10 ++++++- routes/widget/widget_methods.go | 7 +++-- 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/routes/component-configuration/config_methods.go b/routes/component-configuration/config_methods.go index 767b76c..92ec26c 100644 --- a/routes/component-configuration/config_methods.go +++ b/routes/component-configuration/config_methods.go @@ -131,7 +131,6 @@ func (m *ComponentConfiguration) delete() error { } // remove association between ComponentConfiguration and Scenario - // ComponentConfiguration itself is not deleted from DB, it remains as "dangling" err = db.Model(&so).Association("ComponentConfigurations").Delete(m).Error if err != nil { return err @@ -143,6 +142,32 @@ func (m *ComponentConfiguration) delete() error { return err } + // Get Signals of InputMapping and delete them + var InputMappingSignals []database.Signal + err = db.Model(m).Related(&InputMappingSignals, "InputMapping").Error + if err != nil { + return err + } + for sig, _ := range InputMappingSignals { + err = db.Delete(&sig).Error + if err != nil { + return err + } + } + + // Get Signals of OutputMapping and delete them + var OutputMappingSignals []database.Signal + err = db.Model(m).Related(&OutputMappingSignals, "OutputMapping").Error + if err != nil { + return err + } + for sig, _ := range OutputMappingSignals { + err = db.Delete(&sig).Error + if err != nil { + return err + } + } + // delete component configuration err = db.Delete(m).Error if err != nil { diff --git a/routes/dashboard/dashboard_methods.go b/routes/dashboard/dashboard_methods.go index 2a9b438..913b93c 100644 --- a/routes/dashboard/dashboard_methods.go +++ b/routes/dashboard/dashboard_methods.go @@ -30,9 +30,9 @@ type Dashboard struct { database.Dashboard } -func (v *Dashboard) save() error { +func (d *Dashboard) save() error { db := database.GetDB() - err := db.Create(v).Error + err := db.Create(d).Error return err } @@ -90,8 +90,20 @@ func (d *Dashboard) delete() error { // remove association between Dashboard and Scenario err = db.Model(&sim).Association("Dashboards").Delete(d).Error - // Dashboard itself is not deleted from DB, it remains as "dangling" - // TODO: delete dashboard and associated widgets + // get all widgets of the dashboard + var widgets []database.Widget + err = db.Order("ID asc").Model(d).Related(&widgets, "Widgets").Error + if err != nil { + return err + } + + // Delete widgets + for widget, _ := range widgets { + err = db.Delete(&widget).Error + } + + // Delete dashboard + err = db.Delete(d).Error return err } diff --git a/routes/signal/signal_methods.go b/routes/signal/signal_methods.go index eddf1c5..dfbd1ce 100644 --- a/routes/signal/signal_methods.go +++ b/routes/signal/signal_methods.go @@ -107,7 +107,6 @@ func (s *Signal) delete() error { } // remove association between Signal and ComponentConfiguration - // Signal itself is not deleted from DB, it remains as "dangling" if s.Direction == "in" { err = db.Model(&m).Association("InputMapping").Delete(s).Error if err != nil { @@ -117,6 +116,9 @@ func (s *Signal) delete() error { // Reduce length of mapping by 1 var newInputLength = m.InputLength - 1 err = db.Model(m).Update("InputLength", newInputLength).Error + if err != nil { + return err + } } else { err = db.Model(&m).Association("OutputMapping").Delete(s).Error @@ -127,7 +129,13 @@ func (s *Signal) delete() error { // Reduce length of mapping by 1 var newOutputLength = m.OutputLength - 1 err = db.Model(m).Update("OutputLength", newOutputLength).Error + if err != nil { + return err + } } + // Delete signal + err = db.Delete(s).Error + return err } diff --git a/routes/widget/widget_methods.go b/routes/widget/widget_methods.go index 98eb40f..ff58c6a 100644 --- a/routes/widget/widget_methods.go +++ b/routes/widget/widget_methods.go @@ -96,10 +96,13 @@ func (w *Widget) delete() error { } // remove association between Dashboard and Widget - // Widget itself is not deleted from DB, it remains as "dangling" err = db.Model(&dab).Association("Widgets").Delete(w).Error + if err != nil { + return err + } - // TODO: What about files that belong to a widget? Keep them or remove them here? + // Delete Widget + err = db.Delete(w).Error return err } From 6689c858d66b43d1a3d504bb892b8537f4067222 Mon Sep 17 00:00:00 2001 From: Sonja Happ Date: Fri, 20 Nov 2020 14:01:29 +0100 Subject: [PATCH 06/11] expose some functions of file package, add more MIME types to file download function to cover result file formats --- routes/file/file_endpoints.go | 19 ++++++++++++++----- routes/file/file_methods.go | 6 +++--- routes/file/file_middleware.go | 4 ++-- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/routes/file/file_endpoints.go b/routes/file/file_endpoints.go index ba52a9e..df1ae77 100644 --- a/routes/file/file_endpoints.go +++ b/routes/file/file_endpoints.go @@ -104,7 +104,7 @@ func addFile(c *gin.Context) { } var newFile File - err = newFile.register(file_header, so.ID) + err = newFile.Register(file_header, so.ID) if !helper.DBError(c, err) { c.JSON(http.StatusOK, gin.H{"file": newFile.File}) } @@ -116,6 +116,15 @@ func addFile(c *gin.Context) { // @ID getFile // @Tags files // @Produce text/plain +// @Produce text/csv +// @Produce application/gzip +// @Produce application/x-gtar +// @Produce application/x-tar +// @Produce application/x-ustar +// @Produce application/zip +// @Produce application/msexcel +// @Produce application/xml +// @Produce application/x-bag // @Produce png // @Produce jpeg // @Produce gif @@ -132,7 +141,7 @@ func addFile(c *gin.Context) { func getFile(c *gin.Context) { // check access - ok, f := checkPermissions(c, database.Read) + ok, f := CheckPermissions(c, database.Read) if !ok { return } @@ -165,7 +174,7 @@ func getFile(c *gin.Context) { func updateFile(c *gin.Context) { // check access - ok, f := checkPermissions(c, database.Update) + ok, f := CheckPermissions(c, database.Update) if !ok { return } @@ -199,12 +208,12 @@ func updateFile(c *gin.Context) { func deleteFile(c *gin.Context) { // check access - ok, f := checkPermissions(c, database.Delete) + ok, f := CheckPermissions(c, database.Delete) if !ok { return } - err := f.delete() + err := f.Delete() if !helper.DBError(c, err) { c.JSON(http.StatusOK, gin.H{"file": f.File}) } diff --git a/routes/file/file_methods.go b/routes/file/file_methods.go index 1694ede..2e3eae0 100644 --- a/routes/file/file_methods.go +++ b/routes/file/file_methods.go @@ -44,7 +44,7 @@ type File struct { database.File } -func (f *File) byID(id uint) error { +func (f *File) ByID(id uint) error { db := database.GetDB() err := db.Find(f, id).Error if err != nil { @@ -74,7 +74,7 @@ func (f *File) download(c *gin.Context) error { return nil } -func (f *File) register(fileHeader *multipart.FileHeader, scenarioID uint) error { +func (f *File) Register(fileHeader *multipart.FileHeader, scenarioID uint) error { // Obtain properties of file f.Type = fileHeader.Header.Get("Content-Type") @@ -179,7 +179,7 @@ func (f *File) update(fileHeader *multipart.FileHeader) error { return err } -func (f *File) delete() error { +func (f *File) Delete() error { db := database.GetDB() diff --git a/routes/file/file_middleware.go b/routes/file/file_middleware.go index 3adf779..acdacb6 100644 --- a/routes/file/file_middleware.go +++ b/routes/file/file_middleware.go @@ -29,7 +29,7 @@ import ( "github.com/gin-gonic/gin" ) -func checkPermissions(c *gin.Context, operation database.CRUD) (bool, File) { +func CheckPermissions(c *gin.Context, operation database.CRUD) (bool, File) { var f File @@ -44,7 +44,7 @@ func checkPermissions(c *gin.Context, operation database.CRUD) (bool, File) { return false, f } - err = f.byID(uint(fileID)) + err = f.ByID(uint(fileID)) if helper.DBError(c, err) { return false, f } From 3f45e286f985dd6a66bad94d484511c4764e010d Mon Sep 17 00:00:00 2001 From: Sonja Happ Date: Fri, 20 Nov 2020 14:04:57 +0100 Subject: [PATCH 07/11] WIP: add license statement to new files, implement addResultFile and deleteResultFile endpoints, implement delete method for Result; first draft for test --- routes/result/result_endpoints.go | 131 +++++++++++++++++++---------- routes/result/result_methods.go | 75 ++++++++++++++++- routes/result/result_middleware.go | 24 +++++- routes/result/result_test.go | 119 ++++++++++++++++++++++++++ routes/result/result_validators.go | 22 +++++ 5 files changed, 323 insertions(+), 48 deletions(-) diff --git a/routes/result/result_endpoints.go b/routes/result/result_endpoints.go index ccb734b..c1e44e9 100644 --- a/routes/result/result_endpoints.go +++ b/routes/result/result_endpoints.go @@ -1,8 +1,32 @@ +/** Result package, endpoints. +* +* @author Sonja Happ +* @copyright 2014-2019, Institute for Automation of Complex Power Systems, EONERC +* @license GNU General Public License (version 3) +* +* VILLASweb-backend-go +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see . +*********************************************************************************/ + package result import ( + "fmt" "git.rwth-aachen.de/acs/public/villas/web-backend-go/database" "git.rwth-aachen.de/acs/public/villas/web-backend-go/helper" + "git.rwth-aachen.de/acs/public/villas/web-backend-go/routes/file" "git.rwth-aachen.de/acs/public/villas/web-backend-go/routes/scenario" "github.com/gin-gonic/gin" "net/http" @@ -15,7 +39,6 @@ func RegisterResultEndpoints(r *gin.RouterGroup) { r.GET("/:resultID", getResult) r.DELETE("/:resultID", deleteResult) r.POST("/:resultID/file", addResultFile) - r.GET("/:resultID/file/:fileID", getResultFile) r.DELETE("/:resultID/file/:fileID", deleteResultFile) } @@ -33,16 +56,16 @@ func RegisterResultEndpoints(r *gin.RouterGroup) { // @Security Bearer func getResults(c *gin.Context) { - ok, scenario := scenario.CheckPermissions(c, database.Read, "query", -1) + ok, sco := scenario.CheckPermissions(c, database.Read, "query", -1) if !ok { return } db := database.GetDB() - var result []database.Result - err := db.Order("ID asc").Model(scenario).Related(&result, "Results").Error + var results []database.Result + err := db.Order("ID asc").Model(sco).Related(&results, "Results").Error if !helper.DBError(c, err) { - c.JSON(http.StatusOK, gin.H{"result": result}) + c.JSON(http.StatusOK, gin.H{"results": results}) } } @@ -109,7 +132,7 @@ func addResult(c *gin.Context) { // @Security Bearer func updateResult(c *gin.Context) { - ok, oldResult := CheckPermissions(c, database.Update, "path", -1) + ok, oldResult := checkPermissions(c, database.Update, "path", -1) if !ok { return } @@ -151,7 +174,7 @@ func updateResult(c *gin.Context) { // @Security Bearer func getResult(c *gin.Context) { - ok, result := CheckPermissions(c, database.Read, "path", -1) + ok, result := checkPermissions(c, database.Read, "path", -1) if !ok { return } @@ -160,7 +183,7 @@ func getResult(c *gin.Context) { } // deleteResult godoc -// @Summary Delete a Result +// @Summary Delete a Result incl. all result files // @ID deleteResult // @Tags results // @Produce json @@ -173,7 +196,13 @@ func getResult(c *gin.Context) { // @Router /results/{resultID} [delete] // @Security Bearer func deleteResult(c *gin.Context) { - ok, result := CheckPermissions(c, database.Delete, "path", -1) + ok, result := checkPermissions(c, database.Delete, "path", -1) + if !ok { + return + } + + // Check if user is allowed to modify scenario associated with result + ok, _ = scenario.CheckPermissions(c, database.Update, "body", int(result.ScenarioID)) if !ok { return } @@ -210,49 +239,37 @@ func deleteResult(c *gin.Context) { // @Router /results/{resultID}/file [post] // @Security Bearer func addResultFile(c *gin.Context) { - ok, _ := CheckPermissions(c, database.Update, "path", -1) + ok, result := checkPermissions(c, database.Update, "path", -1) if !ok { return } - // TODO check permissions of scenario first (file will be added to scenario) - - // TODO add file to DB, associate with scenario and add file ID to result - -} - -// getResultFile godoc -// @Summary Download a result file -// @ID getResultFile -// @Tags results -// @Produce text/plain -// @Produce text/csv -// @Produce application/gzip -// @Produce application/x-gtar -// @Produce application/x-tar -// @Produce application/x-ustar -// @Produce application/zip -// @Produce application/msexcel -// @Produce application/xml -// @Produce application/x-bag -// @Success 200 {object} docs.ResponseFile "File that was requested" -// @Failure 400 {object} docs.ResponseError "Bad request" -// @Failure 404 {object} docs.ResponseError "Not found" -// @Failure 422 {object} docs.ResponseError "Unprocessable entity" -// @Failure 500 {object} docs.ResponseError "Internal server error" -// @Param resultID path int true "Result ID" -// @Param fileID path int true "ID of the file to download" -// @Router /results/{resultID}/file/{fileID} [get] -// @Security Bearer -func getResultFile(c *gin.Context) { - - // check access - ok, _ := CheckPermissions(c, database.Read, "path", -1) + // Check if user is allowed to modify scenario associated with result + ok, sco := scenario.CheckPermissions(c, database.Update, "body", int(result.ScenarioID)) if !ok { return } - // TODO download result file + // Extract file from POST request form + file_header, err := c.FormFile("file") + if err != nil { + helper.BadRequestError(c, fmt.Sprintf("Get form error: %s", err.Error())) + return + } + + // save result file to DB and associate it with scenario + var newFile file.File + err = newFile.Register(file_header, sco.ID) + if helper.DBError(c, err) { + return + } + + // add file ID to ResultFileIDs of Result + err = result.addResultFileID(newFile.File.ID) + if !helper.DBError(c, err) { + c.JSON(http.StatusOK, gin.H{"result": result.Result}) + } + } // deleteResultFile godoc @@ -270,12 +287,34 @@ func getResultFile(c *gin.Context) { // @Router /results/{resultID}/file/{fileID} [delete] // @Security Bearer func deleteResultFile(c *gin.Context) { - // TODO check access to scenario (file deletion) first // check access - ok, _ := CheckPermissions(c, database.Update, "path", -1) + ok, result := checkPermissions(c, database.Update, "path", -1) if !ok { return } + ok, f := file.CheckPermissions(c, database.Delete) + if !ok { + return + } + + // Check if user is allowed to modify scenario associated with result + ok, _ = scenario.CheckPermissions(c, database.Update, "body", int(result.ScenarioID)) + if !ok { + return + } + + // remove file ID from ResultFileIDs of Result + err := result.removeResultFileID(f.ID) + if helper.DBError(c, err) { + return + } + + // Delete the file + err = f.Delete() + if !helper.DBError(c, err) { + c.JSON(http.StatusOK, gin.H{"result": result.Result}) + } + } diff --git a/routes/result/result_methods.go b/routes/result/result_methods.go index 48fb528..8512e7f 100644 --- a/routes/result/result_methods.go +++ b/routes/result/result_methods.go @@ -1,8 +1,32 @@ +/** Result package, methods. +* +* @author Sonja Happ +* @copyright 2014-2019, Institute for Automation of Complex Power Systems, EONERC +* @license GNU General Public License (version 3) +* +* VILLASweb-backend-go +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see . +*********************************************************************************/ + package result import ( "git.rwth-aachen.de/acs/public/villas/web-backend-go/database" + "git.rwth-aachen.de/acs/public/villas/web-backend-go/routes/file" "git.rwth-aachen.de/acs/public/villas/web-backend-go/routes/scenario" + "log" ) type Result struct { @@ -69,7 +93,56 @@ func (r *Result) delete() error { // remove association between Result and Scenario err = db.Model(&sco).Association("Results").Delete(r).Error - // TODO delete Result + files (if any) + // Delete result files + for id, _ := range r.ResultFileIDs { + var f file.File + err := f.ByID(uint(id)) + if err != nil { + log.Println("Unable to delete file with ID ", id, err) + continue + } + err = f.Delete() + if err != nil { + return err + } + } + + // Delete result + err = db.Delete(r).Error + + return err +} + +func (r *Result) addResultFileID(fileID uint) error { + + oldResultFileIDs := r.ResultFileIDs + newResultFileIDs := append(oldResultFileIDs, int64(fileID)) + + db := database.GetDB() + + err := db.Model(r).Updates(map[string]interface{}{ + "ResultFileIDs": newResultFileIDs, + }).Error + + return err + +} + +func (r *Result) removeResultFileID(fileID uint) error { + oldResultFileIDs := r.ResultFileIDs + var newResultFileIDs []int64 + + for _, id := range oldResultFileIDs { + if id != int64(fileID) { + newResultFileIDs = append(newResultFileIDs, id) + } + } + + db := database.GetDB() + + err := db.Model(r).Updates(map[string]interface{}{ + "ResultFileIDs": newResultFileIDs, + }).Error return err } diff --git a/routes/result/result_middleware.go b/routes/result/result_middleware.go index 70a199b..7656087 100644 --- a/routes/result/result_middleware.go +++ b/routes/result/result_middleware.go @@ -1,3 +1,25 @@ +/** Result package, middleware. +* +* @author Sonja Happ +* @copyright 2014-2019, Institute for Automation of Complex Power Systems, EONERC +* @license GNU General Public License (version 3) +* +* VILLASweb-backend-go +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see . +*********************************************************************************/ + package result import ( @@ -8,7 +30,7 @@ import ( "github.com/gin-gonic/gin" ) -func CheckPermissions(c *gin.Context, operation database.CRUD, resultIDSource string, resultIDBody int) (bool, Result) { +func checkPermissions(c *gin.Context, operation database.CRUD, resultIDSource string, resultIDBody int) (bool, Result) { var result Result diff --git a/routes/result/result_test.go b/routes/result/result_test.go index 2705049..47dfba9 100644 --- a/routes/result/result_test.go +++ b/routes/result/result_test.go @@ -1 +1,120 @@ +/** Result package, testing. +* +* @author Sonja Happ +* @copyright 2014-2019, Institute for Automation of Complex Power Systems, EONERC +* @license GNU General Public License (version 3) +* +* VILLASweb-backend-go +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see . +*********************************************************************************/ + package result + +import ( + "fmt" + "git.rwth-aachen.de/acs/public/villas/web-backend-go/configuration" + "git.rwth-aachen.de/acs/public/villas/web-backend-go/database" + "git.rwth-aachen.de/acs/public/villas/web-backend-go/helper" + "git.rwth-aachen.de/acs/public/villas/web-backend-go/routes/file" + "git.rwth-aachen.de/acs/public/villas/web-backend-go/routes/scenario" + "git.rwth-aachen.de/acs/public/villas/web-backend-go/routes/user" + "github.com/gin-gonic/gin" + "github.com/jinzhu/gorm/dialects/postgres" + "os" + "testing" +) + +var router *gin.Engine + +type ScenarioRequest struct { + Name string `json:"name,omitempty"` + Running bool `json:"running,omitempty"` + StartParameters postgres.Jsonb `json:"startParameters,omitempty"` +} + +func addScenario() (scenarioID uint) { + + // authenticate as admin + token, _ := helper.AuthenticateForTest(router, + "/api/authenticate", "POST", helper.AdminCredentials) + + // authenticate as normal user + token, _ = helper.AuthenticateForTest(router, + "/api/authenticate", "POST", helper.UserACredentials) + + // POST $newScenario + newScenario := ScenarioRequest{ + Name: helper.ScenarioA.Name, + Running: helper.ScenarioA.Running, + StartParameters: helper.ScenarioA.StartParameters, + } + _, resp, _ := helper.TestEndpoint(router, token, + "/api/scenarios", "POST", helper.KeyModels{"scenario": newScenario}) + + // Read newScenario's ID from the response + newScenarioID, _ := helper.GetResponseID(resp) + + // add the guest user to the new scenario + _, resp, _ = helper.TestEndpoint(router, token, + fmt.Sprintf("/api/scenarios/%v/user?username=User_C", newScenarioID), "PUT", nil) + + return uint(newScenarioID) +} + +func TestMain(m *testing.M) { + err := configuration.InitConfig() + if err != nil { + panic(m) + } + err = database.InitDB(configuration.GolbalConfig) + if err != nil { + panic(m) + } + defer database.DBpool.Close() + + router = gin.Default() + api := router.Group("/api") + + user.RegisterAuthenticate(api.Group("/authenticate")) + api.Use(user.Authentication(true)) + // scenario endpoints required here to first add a scenario to the DB + scenario.RegisterScenarioEndpoints(api.Group("/scenarios")) + // file endpoints required to download result file + file.RegisterFileEndpoints(api.Group("/files")) + + RegisterResultEndpoints(api.Group("/results")) + + os.Exit(m.Run()) +} + +func TestGetAllResultsOfScenario(t *testing.T) { + +} + +func TestAddGetUpdateResult(t *testing.T) { + +} + +func TestDeleteResult(t *testing.T) { + +} + +func TestAddResultFile(t *testing.T) { + +} + +func TestDeleteResultFile(t *testing.T) { + +} diff --git a/routes/result/result_validators.go b/routes/result/result_validators.go index 1f4da71..28e84a1 100644 --- a/routes/result/result_validators.go +++ b/routes/result/result_validators.go @@ -1,3 +1,25 @@ +/** Result package, validators. +* +* @author Sonja Happ +* @copyright 2014-2019, Institute for Automation of Complex Power Systems, EONERC +* @license GNU General Public License (version 3) +* +* VILLASweb-backend-go +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see . +*********************************************************************************/ + package result import ( From 601eb746ce16cf8573ff889d27865c4b15bceefe Mon Sep 17 00:00:00 2001 From: Sonja Happ Date: Thu, 3 Dec 2020 12:03:34 +0100 Subject: [PATCH 08/11] scenario: minor security fix for access to all scenarios --- routes/scenario/scenario_endpoints.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/routes/scenario/scenario_endpoints.go b/routes/scenario/scenario_endpoints.go index 72d077b..0326637 100644 --- a/routes/scenario/scenario_endpoints.go +++ b/routes/scenario/scenario_endpoints.go @@ -59,7 +59,6 @@ func getScenarios(c *gin.Context) { // ATTENTION: do not use c.GetInt (common.UserIDCtx) since user_id is of type uint and not int userID, _ := c.Get(database.UserIDCtx) - userRole, _ := c.Get(database.UserRoleCtx) var u user.User err := u.ByID(userID.(uint)) @@ -70,7 +69,7 @@ func getScenarios(c *gin.Context) { // get all scenarios for the user who issues the request db := database.GetDB() var scenarios []database.Scenario - if userRole == "Admin" { // Admin can see all scenarios + if u.Role == "Admin" { // Admin can see all scenarios err = db.Order("ID asc").Find(&scenarios).Error if helper.DBError(c, err) { return From e3af6dc8befc5c2b4d665a0f6b1edd3be3d5c01a Mon Sep 17 00:00:00 2001 From: Sonja Happ Date: Mon, 4 Jan 2021 14:25:55 +0100 Subject: [PATCH 09/11] add new customProperties for color settings to Button test widget --- helper/test_data.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helper/test_data.go b/helper/test_data.go index fcf0954..7f7f918 100644 --- a/helper/test_data.go +++ b/helper/test_data.go @@ -251,7 +251,7 @@ var FileD = database.File{ var customPropertiesBox = json.RawMessage(`{"border_color" : "#4287f5", "border_color_opacity": 1, "background_color" : "#961520", "background_color_opacity" : 1}`) var customPropertiesSlider = json.RawMessage(`{"default_value" : 0, "orientation" : 0, "rangeUseMinMax": false, "rangeMin" : 0, "rangeMax": 200, "rangeUseMinMax" : true, "showUnit": true, "continous_update": false, "value": "", "resizeLeftRightLock": false, "resizeTopBottomLock": true, "step": 0.1 }`) var customPropertiesLabel = json.RawMessage(`{"textSize" : "20", "fontColor" : "#4287f5", "fontColor_opacity": 1}`) -var customPropertiesButton = json.RawMessage(`{"pressed": false, "toggle" : false, "on_value" : 1, "off_value" : 0, "background_color": "#961520", "font_color": "#4287f5"}`) +var customPropertiesButton = json.RawMessage(`{"pressed": false, "toggle" : false, "on_value" : 1, "off_value" : 0, "background_color": "#961520", "font_color": "#4287f5", "border_color": "#4287f5", "background_color_opacity": 1}`) var customPropertiesLamp = json.RawMessage(`{"signal" : 0, "on_color" : "#4287f5", "off_color": "#961520", "threshold" : 0.5, "on_color_opacity": 1, "off_color_opacity": 1}`) var WidgetA = database.Widget{ From 1d1b3388a395954168493860acf804cce62a5bc7 Mon Sep 17 00:00:00 2001 From: Sonja Happ Date: Tue, 12 Jan 2021 16:55:15 +0100 Subject: [PATCH 10/11] Add testing for result endpoints, fix some bugs --- database/models.go | 2 +- routes/result/result_methods.go | 6 +- routes/result/result_test.go | 402 ++++++++++++++++++++++++++++- routes/result/result_validators.go | 28 +- 4 files changed, 418 insertions(+), 20 deletions(-) diff --git a/database/models.go b/database/models.go index d37c3d4..ac98506 100644 --- a/database/models.go +++ b/database/models.go @@ -219,7 +219,7 @@ type File struct { type Result struct { Model // JSON snapshots of component configurations used to generate results - ConfigSnapshots []postgres.Jsonb `json:"configSnapshots"` + ConfigSnapshots postgres.Jsonb `json:"configSnapshots"` // Description of results Description string `json:"description"` // ID of Scenario to which result belongs diff --git a/routes/result/result_methods.go b/routes/result/result_methods.go index 8512e7f..cad7c55 100644 --- a/routes/result/result_methods.go +++ b/routes/result/result_methods.go @@ -94,11 +94,11 @@ func (r *Result) delete() error { err = db.Model(&sco).Association("Results").Delete(r).Error // Delete result files - for id, _ := range r.ResultFileIDs { + for _, fileid := range r.ResultFileIDs { var f file.File - err := f.ByID(uint(id)) + err := f.ByID(uint(fileid)) if err != nil { - log.Println("Unable to delete file with ID ", id, err) + log.Println("Unable to delete file with ID ", fileid, err) continue } err = f.Delete() diff --git a/routes/result/result_test.go b/routes/result/result_test.go index 47dfba9..c57596e 100644 --- a/routes/result/result_test.go +++ b/routes/result/result_test.go @@ -23,6 +23,8 @@ package result import ( + "bytes" + "encoding/json" "fmt" "git.rwth-aachen.de/acs/public/villas/web-backend-go/configuration" "git.rwth-aachen.de/acs/public/villas/web-backend-go/database" @@ -32,11 +34,19 @@ import ( "git.rwth-aachen.de/acs/public/villas/web-backend-go/routes/user" "github.com/gin-gonic/gin" "github.com/jinzhu/gorm/dialects/postgres" + "github.com/stretchr/testify/assert" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "net/http/httptest" "os" "testing" ) var router *gin.Engine +var base_api_results = "/api/results" +var base_api_auth = "/api/authenticate" type ScenarioRequest struct { Name string `json:"name,omitempty"` @@ -44,6 +54,16 @@ type ScenarioRequest struct { StartParameters postgres.Jsonb `json:"startParameters,omitempty"` } +type ResultRequest struct { + Description string `json:"description,omitempty"` + ScenarioID uint `json:"scenarioID,omitempty"` + ConfigSnapshots postgres.Jsonb `json:"configSnapshots,omitempty"` +} + +type ResponseResult struct { + Result database.Result `json:"result"` +} + func addScenario() (scenarioID uint) { // authenticate as admin @@ -101,20 +121,386 @@ func TestMain(m *testing.M) { func TestGetAllResultsOfScenario(t *testing.T) { -} + database.DropTables() + database.MigrateModels() + assert.NoError(t, helper.DBAddAdminAndUserAndGuest()) -func TestAddGetUpdateResult(t *testing.T) { + // prepare the content of the DB for testing + // by adding a scenario + scenarioID := addScenario() + + // authenticate as normal user + token, err := helper.AuthenticateForTest(router, + base_api_auth, "POST", helper.UserACredentials) + assert.NoError(t, err) + + // test POST newResult + configSnapshot1 := json.RawMessage(`{"configs": [ {"Name" : "conf1", "scenarioID" : 1}, {"Name" : "conf2", "scenarioID" : 1}]}`) + confSnapshots := postgres.Jsonb{configSnapshot1} + + newResult := ResultRequest{ + Description: "This is a test result.", + ScenarioID: scenarioID, + ConfigSnapshots: confSnapshots, + } + + code, resp, err := helper.TestEndpoint(router, token, + base_api_results, "POST", helper.KeyModels{"result": newResult}) + assert.NoError(t, err) + assert.Equalf(t, 200, code, "Response body: \n%v\n", resp) + + // Count the number of all the results returned for scenario + NumberOfConfigs, err := helper.LengthOfResponse(router, token, + fmt.Sprintf("%v?scenarioID=%v", base_api_results, scenarioID), "GET", nil) + assert.NoError(t, err) + + assert.Equal(t, 1, NumberOfConfigs) + + // authenticate as normal userB who has no access to scenario + token, err = helper.AuthenticateForTest(router, + base_api_auth, "POST", helper.UserBCredentials) + assert.NoError(t, err) + + // try to get results without access + // should result in unprocessable entity + code, resp, err = helper.TestEndpoint(router, token, + fmt.Sprintf("%v?scenarioID=%v", base_api_results, scenarioID), "GET", nil) + assert.NoError(t, err) + assert.Equalf(t, 422, code, "Response body: \n%v\n", resp) } -func TestDeleteResult(t *testing.T) { +func TestAddGetUpdateDeleteResult(t *testing.T) { + + database.DropTables() + database.MigrateModels() + assert.NoError(t, helper.DBAddAdminAndUserAndGuest()) + + // prepare the content of the DB for testing + // by adding a scenario + scenarioID := addScenario() + configSnapshot1 := json.RawMessage(`{"configs": [ {"Name" : "conf1", "scenarioID" : 1}, {"Name" : "conf2", "scenarioID" : 1}]}`) + confSnapshots := postgres.Jsonb{configSnapshot1} + + newResult := ResultRequest{ + Description: "This is a test result.", + ScenarioID: scenarioID, + ConfigSnapshots: confSnapshots, + } + + // authenticate as normal userB who has no access to new scenario + token, err := helper.AuthenticateForTest(router, + base_api_auth, "POST", helper.UserBCredentials) + assert.NoError(t, err) + + // try to POST with no access + // should result in unprocessable entity + code, resp, err := helper.TestEndpoint(router, token, + base_api_results, "POST", helper.KeyModels{"result": newResult}) + assert.NoError(t, err) + assert.Equalf(t, 422, code, "Response body: \n%v\n", resp) + + // authenticate as normal user + token, err = helper.AuthenticateForTest(router, + base_api_auth, "POST", helper.UserACredentials) + assert.NoError(t, err) + + // try to POST non JSON body + code, resp, err = helper.TestEndpoint(router, token, + base_api_results, "POST", "this is not JSON") + assert.NoError(t, err) + assert.Equalf(t, 400, code, "Response body: \n%v\n", resp) + + // test POST newResult + code, resp, err = helper.TestEndpoint(router, token, + base_api_results, "POST", helper.KeyModels{"result": newResult}) + assert.NoError(t, err) + assert.Equalf(t, 200, code, "Response body: \n%v\n", resp) + + // Compare POST's response with the newResult + err = helper.CompareResponse(resp, helper.KeyModels{"result": newResult}) + assert.NoError(t, err) + + // Read newResults's ID from the response + newResultID, err := helper.GetResponseID(resp) + assert.NoError(t, err) + + // Get the newResult + code, resp, err = helper.TestEndpoint(router, token, + fmt.Sprintf("%v/%v", base_api_results, newResultID), "GET", nil) + assert.NoError(t, err) + assert.Equalf(t, 200, code, "Response body: \n%v\n", resp) + + // Compare GET's response with the newResult + err = helper.CompareResponse(resp, helper.KeyModels{"result": newResult}) + assert.NoError(t, err) + + // try to POST a malformed result + // Required fields are missing + malformedNewResult := ResultRequest{ + Description: "ThisIsAMalformedRequest", + } + // this should NOT work and return a unprocessable entity 442 status code + code, resp, err = helper.TestEndpoint(router, token, + base_api_results, "POST", helper.KeyModels{"result": malformedNewResult}) + assert.NoError(t, err) + assert.Equalf(t, 422, code, "Response body: \n%v\n", resp) + + // authenticate as normal userB who has no access to new scenario + token, err = helper.AuthenticateForTest(router, + base_api_auth, "POST", helper.UserBCredentials) + assert.NoError(t, err) + + // Try to GET the newResult with no access + // Should result in unprocessable entity + code, resp, err = helper.TestEndpoint(router, token, + fmt.Sprintf("%v/%v", base_api_results, newResultID), "GET", nil) + assert.NoError(t, err) + assert.Equalf(t, 422, code, "Response body: \n%v\n", resp) + + // Test UPDATE/ PUT + + updatedResult := ResultRequest{ + Description: "This is an updated description", + ConfigSnapshots: confSnapshots, + } + + // try to PUT with no access + // should result in unprocessable entity + code, resp, err = helper.TestEndpoint(router, token, + fmt.Sprintf("%v/%v", base_api_results, newResultID), "PUT", helper.KeyModels{"result": updatedResult}) + assert.NoError(t, err) + assert.Equalf(t, 422, code, "Response body: \n%v\n", resp) + + // authenticate as guest user who has access to result + token, err = helper.AuthenticateForTest(router, + base_api_auth, "POST", helper.GuestCredentials) + assert.NoError(t, err) + + // try to PUT as guest + // should NOT work and result in unprocessable entity + code, resp, err = helper.TestEndpoint(router, token, + fmt.Sprintf("%v/%v", base_api_results, newResultID), "PUT", helper.KeyModels{"result": updatedResult}) + assert.NoError(t, err) + assert.Equalf(t, 422, code, "Response body: \n%v\n", resp) + + // authenticate as normal user + token, err = helper.AuthenticateForTest(router, + base_api_auth, "POST", helper.UserACredentials) + assert.NoError(t, err) + + // try to PUT a non JSON body + // should result in a bad request + code, resp, err = helper.TestEndpoint(router, token, + fmt.Sprintf("%v/%v", base_api_results, newResultID), "PUT", "This is not JSON") + assert.NoError(t, err) + assert.Equalf(t, 400, code, "Response body: \n%v\n", resp) + + // test PUT + code, resp, err = helper.TestEndpoint(router, token, + fmt.Sprintf("%v/%v", base_api_results, newResultID), "PUT", helper.KeyModels{"result": updatedResult}) + assert.NoError(t, err) + assert.Equalf(t, 200, code, "Response body: \n%v\n", resp) + + // Compare PUT's response with the updatedResult + err = helper.CompareResponse(resp, helper.KeyModels{"result": updatedResult}) + assert.NoError(t, err) + + // try to update a result that does not exist (should return not found 404 status code) + code, resp, err = helper.TestEndpoint(router, token, + fmt.Sprintf("%v/%v", base_api_results, newResultID+1), "PUT", helper.KeyModels{"result": updatedResult}) + assert.NoError(t, err) + assert.Equalf(t, 404, code, "Response body: \n%v\n", resp) + + // Test DELETE + newResult.Description = updatedResult.Description + + // authenticate as normal userB who has no access to new scenario + token, err = helper.AuthenticateForTest(router, + base_api_auth, "POST", helper.UserBCredentials) + assert.NoError(t, err) + + // try to DELETE with no access + // should result in unprocessable entity + code, resp, err = helper.TestEndpoint(router, token, + fmt.Sprintf("%v/%v", base_api_results, newResultID), "DELETE", nil) + assert.NoError(t, err) + assert.Equalf(t, 422, code, "Response body: \n%v\n", resp) + + // authenticate as normal user + token, err = helper.AuthenticateForTest(router, + base_api_auth, "POST", helper.UserACredentials) + assert.NoError(t, err) + + // Count the number of all the results returned for scenario + initialNumber, err := helper.LengthOfResponse(router, token, + fmt.Sprintf("%v?scenarioID=%v", base_api_results, scenarioID), "GET", nil) + assert.NoError(t, err) + + // Delete the added newResult + code, resp, err = helper.TestEndpoint(router, token, + fmt.Sprintf("%v/%v", base_api_results, newResultID), "DELETE", nil) + assert.NoError(t, err) + assert.Equalf(t, 200, code, "Response body: \n%v\n", resp) + + // Compare DELETE's response with the newResult + err = helper.CompareResponse(resp, helper.KeyModels{"result": newResult}) + assert.NoError(t, err) + + // Again count the number of all the results returned + finalNumber, err := helper.LengthOfResponse(router, token, + fmt.Sprintf("%v?scenarioID=%v", base_api_results, scenarioID), "GET", nil) + assert.NoError(t, err) + + assert.Equal(t, initialNumber-1, finalNumber) } -func TestAddResultFile(t *testing.T) { - -} - -func TestDeleteResultFile(t *testing.T) { +func TestAddDeleteResultFile(t *testing.T) { + database.DropTables() + database.MigrateModels() + assert.NoError(t, helper.DBAddAdminAndUserAndGuest()) + + // prepare the content of the DB for testing + // by adding a scenario + scenarioID := addScenario() + configSnapshot1 := json.RawMessage(`{"configs": [ {"Name" : "conf1", "scenarioID" : 1}, {"Name" : "conf2", "scenarioID" : 1}]}`) + confSnapshots := postgres.Jsonb{configSnapshot1} + + newResult := ResultRequest{ + Description: "This is a test result.", + ScenarioID: scenarioID, + ConfigSnapshots: confSnapshots, + } + + // authenticate as normal user + token, err := helper.AuthenticateForTest(router, + base_api_auth, "POST", helper.UserACredentials) + assert.NoError(t, err) + + // test POST newResult + code, resp, err := helper.TestEndpoint(router, token, + base_api_results, "POST", helper.KeyModels{"result": newResult}) + assert.NoError(t, err) + assert.Equalf(t, 200, code, "Response body: \n%v\n", resp) + + // Compare POST's response with the newResult + err = helper.CompareResponse(resp, helper.KeyModels{"result": newResult}) + assert.NoError(t, err) + + // Read newResults's ID from the response + newResultID, err := helper.GetResponseID(resp) + assert.NoError(t, err) + + // test POST result file + + // create a testfile.txt in local folder + c1 := []byte("a,few,values\n1,2,3\n") + err = ioutil.WriteFile("testfile.csv", c1, 0644) + assert.NoError(t, err) + + bodyBuf := &bytes.Buffer{} + bodyWriter := multipart.NewWriter(bodyBuf) + fileWriter, err := bodyWriter.CreateFormFile("file", "testuploadfile.csv") + assert.NoError(t, err, "writing to buffer") + + // open file handle + fh, err := os.Open("testfile.csv") + assert.NoError(t, err, "opening file") + defer fh.Close() + + // io copy + _, err = io.Copy(fileWriter, fh) + assert.NoError(t, err, "IO copy") + + contentType := bodyWriter.FormDataContentType() + bodyWriter.Close() + + // Create the request + w := httptest.NewRecorder() + req, err := http.NewRequest("POST", fmt.Sprintf("%v/%v/file", base_api_results, newResultID), bodyBuf) + assert.NoError(t, err, "create request") + + req.Header.Set("Content-Type", contentType) + req.Header.Add("Authorization", "Bearer "+token) + router.ServeHTTP(w, req) + + assert.Equalf(t, 200, w.Code, "Response body: \n%v\n", w.Body) + err = helper.CompareResponse(w.Body, helper.KeyModels{"result": newResult}) + + // extract file ID from response body + var respResult ResponseResult + err = json.Unmarshal(w.Body.Bytes(), &respResult) + assert.NoError(t, err, "unmarshal response body") + + assert.Equal(t, 1, len(respResult.Result.ResultFileIDs)) + fileID := respResult.Result.ResultFileIDs[0] + + // DELETE the file + + code, resp, err = helper.TestEndpoint(router, token, + fmt.Sprintf("%v/%v/file/%v", base_api_results, newResultID, fileID), "DELETE", nil) + assert.NoError(t, err) + assert.Equalf(t, 200, code, "Response body: \n%v\n", resp) + + var respResult2 ResponseResult + err = json.Unmarshal(resp.Bytes(), &respResult2) + assert.NoError(t, err, "unmarshal response body") + assert.Equal(t, 0, len(respResult2.Result.ResultFileIDs)) + + // ADD the file again + + bodyBuf2 := &bytes.Buffer{} + bodyWriter2 := multipart.NewWriter(bodyBuf2) + fileWriter2, err := bodyWriter2.CreateFormFile("file", "testuploadfile.csv") + assert.NoError(t, err, "writing to buffer") + + // open file handle + fh2, err := os.Open("testfile.csv") + assert.NoError(t, err, "opening file") + defer fh2.Close() + + // io copy + _, err = io.Copy(fileWriter2, fh2) + assert.NoError(t, err, "IO copy") + + contentType2 := bodyWriter2.FormDataContentType() + bodyWriter2.Close() + + // Create the request + w2 := httptest.NewRecorder() + req2, err := http.NewRequest("POST", fmt.Sprintf("%v/%v/file", base_api_results, newResultID), bodyBuf2) + assert.NoError(t, err, "create request") + + req2.Header.Set("Content-Type", contentType2) + req2.Header.Add("Authorization", "Bearer "+token) + router.ServeHTTP(w2, req2) + + assert.Equalf(t, 200, w2.Code, "Response body: \n%v\n", w2.Body) + err = helper.CompareResponse(w2.Body, helper.KeyModels{"result": newResult}) + + // extract file ID from response body + var respResult3 ResponseResult + err = json.Unmarshal(w2.Body.Bytes(), &respResult3) + assert.NoError(t, err, "unmarshal response body") + + assert.Equal(t, 1, len(respResult3.Result.ResultFileIDs)) + + // DELETE result inlc. file + code, resp, err = helper.TestEndpoint(router, token, + fmt.Sprintf("%v/%v", base_api_results, newResultID), "DELETE", nil) + assert.NoError(t, err) + assert.Equalf(t, 200, code, "Response body: \n%v\n", resp) + + // Compare DELETE's response with the newResult + err = helper.CompareResponse(resp, helper.KeyModels{"result": newResult}) + assert.NoError(t, err) + + // Again count the number of all the results returned + finalNumber, err := helper.LengthOfResponse(router, token, + fmt.Sprintf("%v?scenarioID=%v", base_api_results, scenarioID), "GET", nil) + assert.NoError(t, err) + + assert.Equal(t, 0, finalNumber) } diff --git a/routes/result/result_validators.go b/routes/result/result_validators.go index 28e84a1..c94938f 100644 --- a/routes/result/result_validators.go +++ b/routes/result/result_validators.go @@ -23,23 +23,25 @@ package result import ( + "encoding/json" "github.com/jinzhu/gorm/dialects/postgres" + "github.com/nsf/jsondiff" "gopkg.in/go-playground/validator.v9" ) var validate *validator.Validate type validNewResult struct { - Description string `form:"Description" validate:"omitempty"` - ResultFileIDs []int64 `form:"ResultFileIDs" validate:"omitempty"` - ConfigSnapshots []postgres.Jsonb `form:"ConfigSnapshots" validate:"required"` - ScenarioID uint `form:"ScenarioID" validate:"required"` + Description string `form:"Description" validate:"omitempty"` + ResultFileIDs []int64 `form:"ResultFileIDs" validate:"omitempty"` + ConfigSnapshots postgres.Jsonb `form:"ConfigSnapshots" validate:"required"` + ScenarioID uint `form:"ScenarioID" validate:"required"` } type validUpdatedResult struct { - Description string `form:"Description" validate:"omitempty" json:"description"` - ResultFileIDs []int64 `form:"ResultFileIDs" validate:"omitempty" json:"resultFileIDs"` - ConfigSnapshots []postgres.Jsonb `form:"ConfigSnapshots" validate:"omitempty" json:"configSnapshots"` + Description string `form:"Description" validate:"omitempty" json:"description"` + ResultFileIDs []int64 `form:"ResultFileIDs" validate:"omitempty" json:"resultFileIDs"` + ConfigSnapshots postgres.Jsonb `form:"ConfigSnapshots" validate:"omitempty" json:"configSnapshots"` } type addResultRequest struct { @@ -78,8 +80,18 @@ func (r *updateResultRequest) updatedResult(oldResult Result) Result { s := oldResult s.Result.Description = r.Result.Description - s.ConfigSnapshots = r.Result.ConfigSnapshots s.ResultFileIDs = r.Result.ResultFileIDs + // only update snapshots if not empty + var emptyJson postgres.Jsonb + // Serialize empty json and params + emptyJson_ser, _ := json.Marshal(emptyJson) + configSnapshots_ser, _ := json.Marshal(r.Result.ConfigSnapshots) + opts := jsondiff.DefaultConsoleOptions() + diff, _ := jsondiff.Compare(emptyJson_ser, configSnapshots_ser, &opts) + if diff.String() != "FullMatch" { + s.ConfigSnapshots = r.Result.ConfigSnapshots + } + return s } From 88970d79a76d1c2bfe660628dc37b7296b85a78b Mon Sep 17 00:00:00 2001 From: Sonja Happ Date: Tue, 12 Jan 2021 17:06:02 +0100 Subject: [PATCH 11/11] Update API doc --- doc/api/docs.go | 85 ++++++-------------------------------------- doc/api/swagger.json | 83 ++++++------------------------------------ doc/api/swagger.yaml | 61 ++++++------------------------- 3 files changed, 31 insertions(+), 198 deletions(-) diff --git a/doc/api/docs.go b/doc/api/docs.go index e698b73..e17cda2 100644 --- a/doc/api/docs.go +++ b/doc/api/docs.go @@ -1,6 +1,6 @@ // GENERATED BY THE COMMAND ABOVE; DO NOT EDIT // This file was generated by swaggo/swag at -// 2020-11-19 17:20:42.626650342 +0100 CET m=+0.093632886 +// 2021-01-12 17:04:27.751319166 +0100 CET m=+0.123714746 package docs @@ -806,6 +806,15 @@ var doc = `{ ], "produces": [ "text/plain", + "text/csv", + "application/gzip", + "application/x-gtar", + "application/x-tar", + "application/x-ustar", + "application/zip", + "application/msexcel", + "application/xml", + "application/x-bag", "image/png", "image/jpeg", "image/gif", @@ -1691,7 +1700,7 @@ var doc = `{ "tags": [ "results" ], - "summary": "Delete a Result", + "summary": "Delete a Result incl. all result files", "operationId": "deleteResult", "parameters": [ { @@ -1814,78 +1823,6 @@ var doc = `{ } }, "/results/{resultID}/file/{fileID}": { - "get": { - "security": [ - { - "Bearer": [] - } - ], - "produces": [ - "text/plain", - "text/csv", - "application/gzip", - "application/x-gtar", - "application/x-tar", - "application/x-ustar", - "application/zip", - "application/msexcel", - "application/xml", - "application/x-bag" - ], - "tags": [ - "results" - ], - "summary": "Download a result file", - "operationId": "getResultFile", - "parameters": [ - { - "type": "integer", - "description": "Result ID", - "name": "resultID", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "ID of the file to download", - "name": "fileID", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "File that was requested", - "schema": { - "$ref": "#/definitions/docs.ResponseFile" - } - }, - "400": { - "description": "Bad request", - "schema": { - "$ref": "#/definitions/docs.ResponseError" - } - }, - "404": { - "description": "Not found", - "schema": { - "$ref": "#/definitions/docs.ResponseError" - } - }, - "422": { - "description": "Unprocessable entity", - "schema": { - "$ref": "#/definitions/docs.ResponseError" - } - }, - "500": { - "description": "Internal server error", - "schema": { - "$ref": "#/definitions/docs.ResponseError" - } - } - } - }, "delete": { "security": [ { diff --git a/doc/api/swagger.json b/doc/api/swagger.json index 38c05a2..8fdaab0 100644 --- a/doc/api/swagger.json +++ b/doc/api/swagger.json @@ -789,6 +789,15 @@ ], "produces": [ "text/plain", + "text/csv", + "application/gzip", + "application/x-gtar", + "application/x-tar", + "application/x-ustar", + "application/zip", + "application/msexcel", + "application/xml", + "application/x-bag", "image/png", "image/jpeg", "image/gif", @@ -1674,7 +1683,7 @@ "tags": [ "results" ], - "summary": "Delete a Result", + "summary": "Delete a Result incl. all result files", "operationId": "deleteResult", "parameters": [ { @@ -1797,78 +1806,6 @@ } }, "/results/{resultID}/file/{fileID}": { - "get": { - "security": [ - { - "Bearer": [] - } - ], - "produces": [ - "text/plain", - "text/csv", - "application/gzip", - "application/x-gtar", - "application/x-tar", - "application/x-ustar", - "application/zip", - "application/msexcel", - "application/xml", - "application/x-bag" - ], - "tags": [ - "results" - ], - "summary": "Download a result file", - "operationId": "getResultFile", - "parameters": [ - { - "type": "integer", - "description": "Result ID", - "name": "resultID", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "ID of the file to download", - "name": "fileID", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "File that was requested", - "schema": { - "$ref": "#/definitions/docs.ResponseFile" - } - }, - "400": { - "description": "Bad request", - "schema": { - "$ref": "#/definitions/docs.ResponseError" - } - }, - "404": { - "description": "Not found", - "schema": { - "$ref": "#/definitions/docs.ResponseError" - } - }, - "422": { - "description": "Unprocessable entity", - "schema": { - "$ref": "#/definitions/docs.ResponseError" - } - }, - "500": { - "description": "Internal server error", - "schema": { - "$ref": "#/definitions/docs.ResponseError" - } - } - } - }, "delete": { "security": [ { diff --git a/doc/api/swagger.yaml b/doc/api/swagger.yaml index 74ceced..a863489 100644 --- a/doc/api/swagger.yaml +++ b/doc/api/swagger.yaml @@ -1355,6 +1355,15 @@ paths: type: integer produces: - text/plain + - text/csv + - application/gzip + - application/x-gtar + - application/x-tar + - application/x-ustar + - application/zip + - application/msexcel + - application/xml + - application/x-bag - image/png - image/jpeg - image/gif @@ -1834,7 +1843,7 @@ paths: $ref: '#/definitions/docs.ResponseError' security: - Bearer: [] - summary: Delete a Result + summary: Delete a Result incl. all result files tags: - results get: @@ -2013,56 +2022,6 @@ paths: summary: Delete a result file tags: - results - get: - operationId: getResultFile - parameters: - - description: Result ID - in: path - name: resultID - required: true - type: integer - - description: ID of the file to download - in: path - name: fileID - required: true - type: integer - produces: - - text/plain - - text/csv - - application/gzip - - application/x-gtar - - application/x-tar - - application/x-ustar - - application/zip - - application/msexcel - - application/xml - - application/x-bag - responses: - "200": - description: File that was requested - schema: - $ref: '#/definitions/docs.ResponseFile' - "400": - description: Bad request - schema: - $ref: '#/definitions/docs.ResponseError' - "404": - description: Not found - schema: - $ref: '#/definitions/docs.ResponseError' - "422": - description: Unprocessable entity - schema: - $ref: '#/definitions/docs.ResponseError' - "500": - description: Internal server error - schema: - $ref: '#/definitions/docs.ResponseError' - security: - - Bearer: [] - summary: Download a result file - tags: - - results /scenarios: get: operationId: getScenarios