diff --git a/example-project/cmd/web-api/README.md b/example-project/cmd/web-api/README.md index dbd26db..1aa5a22 100644 --- a/example-project/cmd/web-api/README.md +++ b/example-project/cmd/web-api/README.md @@ -6,7 +6,11 @@ accelerator@geeksinthewoods.com.com ## Description -Service exposes a JSON api. +Web API is a client facing API. Standard response format is JSON. + +**Not all CRUD methods are exposed as endpoints.** Only endpoints that clients may need should be exposed. Internal +services should communicate directly with the business logic packages or a new API should be created to support. This +separation should help decouple client integrations from internal application development. ## Local Installation diff --git a/example-project/cmd/web-api/docs/docs.go b/example-project/cmd/web-api/docs/docs.go index deea6e6..face63b 100644 --- a/example-project/cmd/web-api/docs/docs.go +++ b/example-project/cmd/web-api/docs/docs.go @@ -1,6 +1,6 @@ // GENERATED BY THE COMMAND ABOVE; DO NOT EDIT // This file was generated by swaggo/swag at -// 2019-06-25 06:15:54.005963 -0800 AKDT m=+73.603716546 +// 2019-06-25 22:24:43.036451 -0800 AKDT m=+251.619672639 package docs @@ -31,14 +31,14 @@ var doc = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { - "/accounts/{id}": { - "get": { + "/accounts": { + "patch": { "security": [ { "OAuth2Password": [] } ], - "description": "get string by ID", + "description": "Update updates the specified account in the system.", "consumes": [ "application/json" ], @@ -48,7 +48,70 @@ var doc = `{ "tags": [ "account" ], - "summary": "Read returns the specified account from the system.", + "summary": "Update account by ID", + "parameters": [ + { + "description": "Update fields", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/account.AccountUpdateRequest" + } + } + ], + "responses": { + "201": {}, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, + "/accounts/{id}": { + "get": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Read returns the specified account from the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "account" + ], + "summary": "Get account by ID", "parameters": [ { "type": "string", @@ -63,34 +126,35 @@ var doc = `{ "description": "OK", "schema": { "type": "object", - "$ref": "#/definitions/account.Account" - }, - "headers": { - "Token": { - "type": "string", - "description": "qwerty" - } + "$ref": "#/definitions/account.AccountResponse" } }, "400": { "description": "Bad Request", "schema": { "type": "object", - "$ref": "#/definitions/web.Error" + "$ref": "#/definitions/web.ErrorResponse" } }, "403": { "description": "Forbidden", "schema": { "type": "object", - "$ref": "#/definitions/web.Error" + "$ref": "#/definitions/web.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { "type": "object", - "$ref": "#/definitions/web.Error" + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" } } } @@ -114,6 +178,18 @@ var doc = `{ "user" ], "summary": "Token handles a request to authenticate a user.", + "parameters": [ + { + "enum": [ + "user", + "admin" + ], + "type": "string", + "description": "Scope", + "name": "scope", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -152,6 +228,407 @@ var doc = `{ } } }, + "/project": { + "get": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Find returns the existing projects in the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "List projects", + "parameters": [ + { + "type": "string", + "description": "Filter string, example: name = 'Moon Launch'", + "name": "where", + "in": "query" + }, + { + "type": "string", + "description": "Order columns separated by comma, example: created_at desc", + "name": "order", + "in": "query" + }, + { + "type": "integer", + "description": "Limit, example: 10", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset, example: 20", + "name": "offset", + "in": "query" + }, + { + "type": "boolean", + "description": "Included Archived, example: false", + "name": "included-archived", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/project.ProjectResponse" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, + "/projects": { + "post": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Create inserts a new project into the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Create new project.", + "parameters": [ + { + "description": "Project details", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/project.ProjectCreateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "$ref": "#/definitions/project.ProjectResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + }, + "patch": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Update updates the specified project in the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Update project by ID", + "parameters": [ + { + "description": "Update fields", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/project.ProjectUpdateRequest" + } + } + ], + "responses": { + "201": {}, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, + "/projects/archive": { + "patch": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Archive soft-deletes the specified project from the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Archive project by ID", + "parameters": [ + { + "description": "Update fields", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/project.ProjectArchiveRequest" + } + } + ], + "responses": { + "201": {}, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, + "/projects/{id}": { + "get": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Read returns the specified project from the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get project by ID.", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "$ref": "#/definitions/project.ProjectResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Delete removes the specified project from the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Delete project by ID", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "201": {}, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, "/signup": { "post": { "description": "Signup creates a new account and user in the system.", @@ -183,11 +660,89 @@ var doc = `{ "schema": { "type": "object", "$ref": "#/definitions/signup.SignupResponse" - }, - "headers": { - "Token": { - "type": "string", - "description": "qwerty" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, + "/users": { + "get": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Find returns the existing users in the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "List users", + "parameters": [ + { + "type": "string", + "description": "Filter string, example: name = 'Company Name' and email = 'gabi.may@geeksinthewoods.com'", + "name": "where", + "in": "query" + }, + { + "type": "string", + "description": "Order columns separated by comma, example: created_at desc", + "name": "order", + "in": "query" + }, + { + "type": "integer", + "description": "Limit, example: 10", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset, example: 20", + "name": "offset", + "in": "query" + }, + { + "type": "boolean", + "description": "Included Archived, example: false", + "name": "included-archived", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/user.UserResponse" } } }, @@ -195,14 +750,335 @@ var doc = `{ "description": "Bad Request", "schema": { "type": "object", - "$ref": "#/definitions/web.Error" + "$ref": "#/definitions/web.ErrorResponse" } }, "403": { "description": "Forbidden", "schema": { "type": "object", - "$ref": "#/definitions/web.Error" + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Create inserts a new user into the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Create new user.", + "parameters": [ + { + "description": "User details", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/user.UserCreateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "$ref": "#/definitions/user.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + }, + "patch": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Update updates the specified user in the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Update user by ID", + "parameters": [ + { + "description": "Update fields", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/user.UserUpdateRequest" + } + } + ], + "responses": { + "201": {}, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, + "/users/archive": { + "patch": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Archive soft-deletes the specified user from the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Archive user by ID", + "parameters": [ + { + "description": "Update fields", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/user.UserArchiveRequest" + } + } + ], + "responses": { + "201": {}, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, + "/users/password": { + "patch": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Update updates the password for a specified user in the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Update user password by ID", + "parameters": [ + { + "description": "Update fields", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/user.UserUpdatePasswordRequest" + } + } + ], + "responses": { + "201": {}, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, + "/users/switch-account/{account_id}": { + "patch": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "SwitchAccount updates the auth claims to a new account.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Switch account.", + "parameters": [ + { + "type": "integer", + "description": "Account ID", + "name": "account_id", + "in": "path", + "required": true + } + ], + "responses": { + "201": {}, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" } } } @@ -215,7 +1091,7 @@ var doc = `{ "OAuth2Password": [] } ], - "description": "get string by ID", + "description": "Read returns the specified user from the system.", "consumes": [ "application/json" ], @@ -225,7 +1101,7 @@ var doc = `{ "tags": [ "user" ], - "summary": "Read returns the specified user from the system.", + "summary": "Get user by ID", "parameters": [ { "type": "string", @@ -233,13 +1109,6 @@ var doc = `{ "name": "id", "in": "path", "required": true - }, - { - "type": "string", - "description": "Authentication header", - "name": "Authorization", - "in": "header", - "required": true } ], "responses": { @@ -247,34 +1116,93 @@ var doc = `{ "description": "OK", "schema": { "type": "object", - "$ref": "#/definitions/user.User" - }, - "headers": { - "Token": { - "type": "string", - "description": "qwerty" - } + "$ref": "#/definitions/user.UserResponse" } }, "400": { "description": "Bad Request", "schema": { "type": "object", - "$ref": "#/definitions/web.Error" + "$ref": "#/definitions/web.ErrorResponse" } }, "403": { "description": "Forbidden", "schema": { "type": "object", - "$ref": "#/definitions/web.Error" + "$ref": "#/definitions/web.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { "type": "object", - "$ref": "#/definitions/web.Error" + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Delete removes the specified user from the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Delete user by ID", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "201": {}, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" } } } @@ -282,7 +1210,7 @@ var doc = `{ } }, "definitions": { - "account.Account": { + "account.AccountResponse": { "type": "object", "properties": { "address1": { @@ -294,10 +1222,12 @@ var doc = `{ "example": "Box #1832" }, "archived_at": { - "type": "string" + "type": "object", + "$ref": "#/definitions/web.TimeResponse" }, "billing_user_id": { - "type": "string" + "type": "string", + "example": "d69bdef7-173f-4d29-b52c-3edc60baf6a2" }, "city": { "type": "string", @@ -308,7 +1238,8 @@ var doc = `{ "example": "USA" }, "created_at": { - "type": "string" + "type": "object", + "$ref": "#/definitions/web.TimeResponse" }, "id": { "type": "string", @@ -323,18 +1254,20 @@ var doc = `{ "example": "AK" }, "signup_user_id": { - "type": "string" + "type": "string", + "example": "d69bdef7-173f-4d29-b52c-3edc60baf6a2" }, "status": { - "type": "string", - "example": "active" + "type": "object", + "$ref": "#/definitions/web.EnumResponse" }, "timezone": { "type": "string", "example": "America/Anchorage" }, "updated_at": { - "type": "string" + "type": "object", + "$ref": "#/definitions/web.TimeResponse" }, "zipcode": { "type": "string", @@ -342,6 +1275,166 @@ var doc = `{ } } }, + "account.AccountUpdateRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "address1": { + "type": "string", + "example": "221 Tatitlek Ave" + }, + "address2": { + "type": "string", + "example": "Box #1832" + }, + "billing_user_id": { + "type": "string", + "example": "d69bdef7-173f-4d29-b52c-3edc60baf6a2" + }, + "city": { + "type": "string", + "example": "Valdez" + }, + "country": { + "type": "string", + "example": "USA" + }, + "id": { + "type": "string", + "example": "c4653bf9-5978-48b7-89c5-95704aebb7e2" + }, + "name": { + "type": "string", + "example": "Company Name" + }, + "region": { + "type": "string", + "example": "AK" + }, + "signup_user_id": { + "type": "string", + "example": "d69bdef7-173f-4d29-b52c-3edc60baf6a2" + }, + "status": { + "type": "string", + "enum": [ + "active", + "pending", + "disabled" + ], + "example": "disabled" + }, + "timezone": { + "type": "string", + "example": "America/Anchorage" + }, + "zipcode": { + "type": "string", + "example": "99686" + } + } + }, + "project.ProjectArchiveRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "example": "985f1746-1d9f-459f-a2d9-fc53ece5ae86" + } + } + }, + "project.ProjectCreateRequest": { + "type": "object", + "required": [ + "account_id", + "name" + ], + "properties": { + "account_id": { + "type": "string", + "example": "c4653bf9-5978-48b7-89c5-95704aebb7e2" + }, + "name": { + "type": "string", + "example": "Rocket Launch" + }, + "status": { + "type": "string", + "enum": [ + "active", + "disabled" + ], + "example": "active" + } + } + }, + "project.ProjectResponse": { + "type": "object", + "required": [ + "account_id", + "id", + "name" + ], + "properties": { + "account_id": { + "type": "string", + "example": "c4653bf9-5978-48b7-89c5-95704aebb7e2" + }, + "archived_at": { + "type": "object", + "$ref": "#/definitions/web.TimeResponse" + }, + "created_at": { + "type": "object", + "$ref": "#/definitions/web.TimeResponse" + }, + "id": { + "type": "string", + "example": "985f1746-1d9f-459f-a2d9-fc53ece5ae86" + }, + "name": { + "type": "string", + "example": "Rocket Launch" + }, + "status": { + "type": "object", + "$ref": "#/definitions/web.EnumResponse" + }, + "updated_at": { + "type": "object", + "$ref": "#/definitions/web.TimeResponse" + } + } + }, + "project.ProjectUpdateRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "example": "985f1746-1d9f-459f-a2d9-fc53ece5ae86" + }, + "name": { + "type": "string", + "example": "Rocket Launch to Moon" + }, + "status": { + "type": "string", + "enum": [ + "active", + "disabled" + ], + "example": "disabled" + } + } + }, "signup.SignupRequest": { "type": "object", "properties": { @@ -422,12 +1515,10 @@ var doc = `{ "type": "object", "properties": { "account": { - "type": "object", - "$ref": "#/definitions/account.Account" + "type": "string" }, "user": { - "type": "object", - "$ref": "#/definitions/user.User" + "type": "string" } } }, @@ -445,17 +1536,58 @@ var doc = `{ } } }, - "user.User": { + "user.UserArchiveRequest": { "type": "object", "required": [ - "name" + "id" ], + "properties": { + "id": { + "type": "string", + "example": "d69bdef7-173f-4d29-b52c-3edc60baf6a2" + } + } + }, + "user.UserCreateRequest": { + "type": "object", + "required": [ + "email", + "name", + "password" + ], + "properties": { + "email": { + "type": "string", + "example": "gabi@geeksinthewoods.com" + }, + "name": { + "type": "string", + "example": "Gabi May" + }, + "password": { + "type": "string", + "example": "SecretString" + }, + "password_confirm": { + "type": "string", + "example": "SecretString" + }, + "timezone": { + "type": "string", + "example": "America/Anchorage" + } + } + }, + "user.UserResponse": { + "type": "object", "properties": { "archived_at": { - "type": "string" + "type": "object", + "$ref": "#/definitions/web.TimeResponse" }, "created_at": { - "type": "string" + "type": "object", + "$ref": "#/definitions/web.TimeResponse" }, "email": { "type": "string", @@ -474,7 +1606,89 @@ var doc = `{ "example": "America/Anchorage" }, "updated_at": { - "type": "string" + "type": "object", + "$ref": "#/definitions/web.TimeResponse" + } + } + }, + "user.UserUpdatePasswordRequest": { + "type": "object", + "required": [ + "id", + "password" + ], + "properties": { + "id": { + "type": "string", + "example": "d69bdef7-173f-4d29-b52c-3edc60baf6a2" + }, + "password": { + "type": "string", + "example": "NeverTellSecret" + }, + "password_confirm": { + "type": "string", + "example": "NeverTellSecret" + } + } + }, + "user.UserUpdateRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "email": { + "type": "string", + "example": "gabi.may@geeksinthewoods.com" + }, + "id": { + "type": "string", + "example": "d69bdef7-173f-4d29-b52c-3edc60baf6a2" + }, + "name": { + "type": "string", + "example": "Gabi May Not" + }, + "timezone": { + "type": "string", + "example": "America/Anchorage" + } + } + }, + "web.EnumOption": { + "type": "object", + "properties": { + "selected": { + "type": "boolean", + "example": true + }, + "title": { + "type": "string", + "example": "Active Etc" + }, + "value": { + "type": "string", + "example": "active_etc" + } + } + }, + "web.EnumResponse": { + "type": "object", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/web.EnumOption" + } + }, + "title": { + "type": "string", + "example": "Active Etc" + }, + "value": { + "type": "string", + "example": "active_etc" } } }, @@ -487,13 +1701,87 @@ var doc = `{ "fields": { "type": "array", "items": { - "type": "FieldError" + "$ref": "#/definitions/web.FieldError" } }, "status": { "type": "integer" } } + }, + "web.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/web.FieldError" + } + } + } + }, + "web.FieldError": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "field": { + "type": "string" + } + } + }, + "web.TimeResponse": { + "type": "object", + "properties": { + "date": { + "type": "string", + "example": "2019-06-25" + }, + "kitchen": { + "type": "string", + "example": "3:00AM" + }, + "local": { + "type": "string", + "example": "Tue Jun 25 3:00AM" + }, + "local_date": { + "type": "string", + "example": "Tue Jun 25" + }, + "now_rel_time": { + "type": "string", + "example": "15 hours from now" + }, + "now_time": { + "type": "string", + "example": "5 hours ago" + }, + "rfc1123": { + "type": "string", + "example": "Tue, 25 Jun 2019 03:00:53 AKDT" + }, + "time": { + "type": "string", + "example": "03:00:53" + }, + "timezone": { + "type": "string", + "example": "America/Anchorage" + }, + "value": { + "type": "string", + "example": "2019-06-25T03:00:53.284-08:00" + }, + "value_utc": { + "type": "string", + "example": "2019-06-25T11:00:53.284Z" + } + } } }, "securityDefinitions": { @@ -505,9 +1793,8 @@ var doc = `{ "flow": "password", "tokenUrl": "/v1/oauth/token", "scopes": { - "admin": " Grants read and write access to administrative information", - "read": " Grants read access", - "write": " Grants write access" + "admin": " Grants administrative privileges with role of admin.", + "user": " Grants basic privileges with role of user." } } } diff --git a/example-project/cmd/web-api/docs/swagger.json b/example-project/cmd/web-api/docs/swagger.json index 32568b0..d05c9c3 100644 --- a/example-project/cmd/web-api/docs/swagger.json +++ b/example-project/cmd/web-api/docs/swagger.json @@ -18,14 +18,14 @@ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { - "/accounts/{id}": { - "get": { + "/accounts": { + "patch": { "security": [ { "OAuth2Password": [] } ], - "description": "get string by ID", + "description": "Update updates the specified account in the system.", "consumes": [ "application/json" ], @@ -35,7 +35,70 @@ "tags": [ "account" ], - "summary": "Read returns the specified account from the system.", + "summary": "Update account by ID", + "parameters": [ + { + "description": "Update fields", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/account.AccountUpdateRequest" + } + } + ], + "responses": { + "201": {}, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, + "/accounts/{id}": { + "get": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Read returns the specified account from the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "account" + ], + "summary": "Get account by ID", "parameters": [ { "type": "string", @@ -50,34 +113,35 @@ "description": "OK", "schema": { "type": "object", - "$ref": "#/definitions/account.Account" - }, - "headers": { - "Token": { - "type": "string", - "description": "qwerty" - } + "$ref": "#/definitions/account.AccountResponse" } }, "400": { "description": "Bad Request", "schema": { "type": "object", - "$ref": "#/definitions/web.Error" + "$ref": "#/definitions/web.ErrorResponse" } }, "403": { "description": "Forbidden", "schema": { "type": "object", - "$ref": "#/definitions/web.Error" + "$ref": "#/definitions/web.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { "type": "object", - "$ref": "#/definitions/web.Error" + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" } } } @@ -101,6 +165,18 @@ "user" ], "summary": "Token handles a request to authenticate a user.", + "parameters": [ + { + "enum": [ + "user", + "admin" + ], + "type": "string", + "description": "Scope", + "name": "scope", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -139,6 +215,407 @@ } } }, + "/project": { + "get": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Find returns the existing projects in the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "List projects", + "parameters": [ + { + "type": "string", + "description": "Filter string, example: name = 'Moon Launch'", + "name": "where", + "in": "query" + }, + { + "type": "string", + "description": "Order columns separated by comma, example: created_at desc", + "name": "order", + "in": "query" + }, + { + "type": "integer", + "description": "Limit, example: 10", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset, example: 20", + "name": "offset", + "in": "query" + }, + { + "type": "boolean", + "description": "Included Archived, example: false", + "name": "included-archived", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/project.ProjectResponse" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, + "/projects": { + "post": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Create inserts a new project into the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Create new project.", + "parameters": [ + { + "description": "Project details", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/project.ProjectCreateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "$ref": "#/definitions/project.ProjectResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + }, + "patch": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Update updates the specified project in the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Update project by ID", + "parameters": [ + { + "description": "Update fields", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/project.ProjectUpdateRequest" + } + } + ], + "responses": { + "201": {}, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, + "/projects/archive": { + "patch": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Archive soft-deletes the specified project from the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Archive project by ID", + "parameters": [ + { + "description": "Update fields", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/project.ProjectArchiveRequest" + } + } + ], + "responses": { + "201": {}, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, + "/projects/{id}": { + "get": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Read returns the specified project from the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get project by ID.", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "$ref": "#/definitions/project.ProjectResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Delete removes the specified project from the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Delete project by ID", + "parameters": [ + { + "type": "string", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "201": {}, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, "/signup": { "post": { "description": "Signup creates a new account and user in the system.", @@ -170,11 +647,89 @@ "schema": { "type": "object", "$ref": "#/definitions/signup.SignupResponse" - }, - "headers": { - "Token": { - "type": "string", - "description": "qwerty" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, + "/users": { + "get": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Find returns the existing users in the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "List users", + "parameters": [ + { + "type": "string", + "description": "Filter string, example: name = 'Company Name' and email = 'gabi.may@geeksinthewoods.com'", + "name": "where", + "in": "query" + }, + { + "type": "string", + "description": "Order columns separated by comma, example: created_at desc", + "name": "order", + "in": "query" + }, + { + "type": "integer", + "description": "Limit, example: 10", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset, example: 20", + "name": "offset", + "in": "query" + }, + { + "type": "boolean", + "description": "Included Archived, example: false", + "name": "included-archived", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/user.UserResponse" } } }, @@ -182,14 +737,335 @@ "description": "Bad Request", "schema": { "type": "object", - "$ref": "#/definitions/web.Error" + "$ref": "#/definitions/web.ErrorResponse" } }, "403": { "description": "Forbidden", "schema": { "type": "object", - "$ref": "#/definitions/web.Error" + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Create inserts a new user into the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Create new user.", + "parameters": [ + { + "description": "User details", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/user.UserCreateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "$ref": "#/definitions/user.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + }, + "patch": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Update updates the specified user in the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Update user by ID", + "parameters": [ + { + "description": "Update fields", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/user.UserUpdateRequest" + } + } + ], + "responses": { + "201": {}, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, + "/users/archive": { + "patch": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Archive soft-deletes the specified user from the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Archive user by ID", + "parameters": [ + { + "description": "Update fields", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/user.UserArchiveRequest" + } + } + ], + "responses": { + "201": {}, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, + "/users/password": { + "patch": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Update updates the password for a specified user in the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Update user password by ID", + "parameters": [ + { + "description": "Update fields", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/user.UserUpdatePasswordRequest" + } + } + ], + "responses": { + "201": {}, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, + "/users/switch-account/{account_id}": { + "patch": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "SwitchAccount updates the auth claims to a new account.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Switch account.", + "parameters": [ + { + "type": "integer", + "description": "Account ID", + "name": "account_id", + "in": "path", + "required": true + } + ], + "responses": { + "201": {}, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" } } } @@ -202,7 +1078,7 @@ "OAuth2Password": [] } ], - "description": "get string by ID", + "description": "Read returns the specified user from the system.", "consumes": [ "application/json" ], @@ -212,7 +1088,7 @@ "tags": [ "user" ], - "summary": "Read returns the specified user from the system.", + "summary": "Get user by ID", "parameters": [ { "type": "string", @@ -220,13 +1096,6 @@ "name": "id", "in": "path", "required": true - }, - { - "type": "string", - "description": "Authentication header", - "name": "Authorization", - "in": "header", - "required": true } ], "responses": { @@ -234,34 +1103,93 @@ "description": "OK", "schema": { "type": "object", - "$ref": "#/definitions/user.User" - }, - "headers": { - "Token": { - "type": "string", - "description": "qwerty" - } + "$ref": "#/definitions/user.UserResponse" } }, "400": { "description": "Bad Request", "schema": { "type": "object", - "$ref": "#/definitions/web.Error" + "$ref": "#/definitions/web.ErrorResponse" } }, "403": { "description": "Forbidden", "schema": { "type": "object", - "$ref": "#/definitions/web.Error" + "$ref": "#/definitions/web.ErrorResponse" } }, "404": { "description": "Not Found", "schema": { "type": "object", - "$ref": "#/definitions/web.Error" + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Delete removes the specified user from the system.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Delete user by ID", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "201": {}, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "$ref": "#/definitions/web.ErrorResponse" } } } @@ -269,7 +1197,7 @@ } }, "definitions": { - "account.Account": { + "account.AccountResponse": { "type": "object", "properties": { "address1": { @@ -281,10 +1209,12 @@ "example": "Box #1832" }, "archived_at": { - "type": "string" + "type": "object", + "$ref": "#/definitions/web.TimeResponse" }, "billing_user_id": { - "type": "string" + "type": "string", + "example": "d69bdef7-173f-4d29-b52c-3edc60baf6a2" }, "city": { "type": "string", @@ -295,7 +1225,8 @@ "example": "USA" }, "created_at": { - "type": "string" + "type": "object", + "$ref": "#/definitions/web.TimeResponse" }, "id": { "type": "string", @@ -310,18 +1241,20 @@ "example": "AK" }, "signup_user_id": { - "type": "string" + "type": "string", + "example": "d69bdef7-173f-4d29-b52c-3edc60baf6a2" }, "status": { - "type": "string", - "example": "active" + "type": "object", + "$ref": "#/definitions/web.EnumResponse" }, "timezone": { "type": "string", "example": "America/Anchorage" }, "updated_at": { - "type": "string" + "type": "object", + "$ref": "#/definitions/web.TimeResponse" }, "zipcode": { "type": "string", @@ -329,6 +1262,166 @@ } } }, + "account.AccountUpdateRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "address1": { + "type": "string", + "example": "221 Tatitlek Ave" + }, + "address2": { + "type": "string", + "example": "Box #1832" + }, + "billing_user_id": { + "type": "string", + "example": "d69bdef7-173f-4d29-b52c-3edc60baf6a2" + }, + "city": { + "type": "string", + "example": "Valdez" + }, + "country": { + "type": "string", + "example": "USA" + }, + "id": { + "type": "string", + "example": "c4653bf9-5978-48b7-89c5-95704aebb7e2" + }, + "name": { + "type": "string", + "example": "Company Name" + }, + "region": { + "type": "string", + "example": "AK" + }, + "signup_user_id": { + "type": "string", + "example": "d69bdef7-173f-4d29-b52c-3edc60baf6a2" + }, + "status": { + "type": "string", + "enum": [ + "active", + "pending", + "disabled" + ], + "example": "disabled" + }, + "timezone": { + "type": "string", + "example": "America/Anchorage" + }, + "zipcode": { + "type": "string", + "example": "99686" + } + } + }, + "project.ProjectArchiveRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "example": "985f1746-1d9f-459f-a2d9-fc53ece5ae86" + } + } + }, + "project.ProjectCreateRequest": { + "type": "object", + "required": [ + "account_id", + "name" + ], + "properties": { + "account_id": { + "type": "string", + "example": "c4653bf9-5978-48b7-89c5-95704aebb7e2" + }, + "name": { + "type": "string", + "example": "Rocket Launch" + }, + "status": { + "type": "string", + "enum": [ + "active", + "disabled" + ], + "example": "active" + } + } + }, + "project.ProjectResponse": { + "type": "object", + "required": [ + "account_id", + "id", + "name" + ], + "properties": { + "account_id": { + "type": "string", + "example": "c4653bf9-5978-48b7-89c5-95704aebb7e2" + }, + "archived_at": { + "type": "object", + "$ref": "#/definitions/web.TimeResponse" + }, + "created_at": { + "type": "object", + "$ref": "#/definitions/web.TimeResponse" + }, + "id": { + "type": "string", + "example": "985f1746-1d9f-459f-a2d9-fc53ece5ae86" + }, + "name": { + "type": "string", + "example": "Rocket Launch" + }, + "status": { + "type": "object", + "$ref": "#/definitions/web.EnumResponse" + }, + "updated_at": { + "type": "object", + "$ref": "#/definitions/web.TimeResponse" + } + } + }, + "project.ProjectUpdateRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "example": "985f1746-1d9f-459f-a2d9-fc53ece5ae86" + }, + "name": { + "type": "string", + "example": "Rocket Launch to Moon" + }, + "status": { + "type": "string", + "enum": [ + "active", + "disabled" + ], + "example": "disabled" + } + } + }, "signup.SignupRequest": { "type": "object", "properties": { @@ -409,12 +1502,10 @@ "type": "object", "properties": { "account": { - "type": "object", - "$ref": "#/definitions/account.Account" + "type": "string" }, "user": { - "type": "object", - "$ref": "#/definitions/user.User" + "type": "string" } } }, @@ -432,17 +1523,58 @@ } } }, - "user.User": { + "user.UserArchiveRequest": { "type": "object", "required": [ - "name" + "id" ], + "properties": { + "id": { + "type": "string", + "example": "d69bdef7-173f-4d29-b52c-3edc60baf6a2" + } + } + }, + "user.UserCreateRequest": { + "type": "object", + "required": [ + "email", + "name", + "password" + ], + "properties": { + "email": { + "type": "string", + "example": "gabi@geeksinthewoods.com" + }, + "name": { + "type": "string", + "example": "Gabi May" + }, + "password": { + "type": "string", + "example": "SecretString" + }, + "password_confirm": { + "type": "string", + "example": "SecretString" + }, + "timezone": { + "type": "string", + "example": "America/Anchorage" + } + } + }, + "user.UserResponse": { + "type": "object", "properties": { "archived_at": { - "type": "string" + "type": "object", + "$ref": "#/definitions/web.TimeResponse" }, "created_at": { - "type": "string" + "type": "object", + "$ref": "#/definitions/web.TimeResponse" }, "email": { "type": "string", @@ -461,7 +1593,89 @@ "example": "America/Anchorage" }, "updated_at": { - "type": "string" + "type": "object", + "$ref": "#/definitions/web.TimeResponse" + } + } + }, + "user.UserUpdatePasswordRequest": { + "type": "object", + "required": [ + "id", + "password" + ], + "properties": { + "id": { + "type": "string", + "example": "d69bdef7-173f-4d29-b52c-3edc60baf6a2" + }, + "password": { + "type": "string", + "example": "NeverTellSecret" + }, + "password_confirm": { + "type": "string", + "example": "NeverTellSecret" + } + } + }, + "user.UserUpdateRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "email": { + "type": "string", + "example": "gabi.may@geeksinthewoods.com" + }, + "id": { + "type": "string", + "example": "d69bdef7-173f-4d29-b52c-3edc60baf6a2" + }, + "name": { + "type": "string", + "example": "Gabi May Not" + }, + "timezone": { + "type": "string", + "example": "America/Anchorage" + } + } + }, + "web.EnumOption": { + "type": "object", + "properties": { + "selected": { + "type": "boolean", + "example": true + }, + "title": { + "type": "string", + "example": "Active Etc" + }, + "value": { + "type": "string", + "example": "active_etc" + } + } + }, + "web.EnumResponse": { + "type": "object", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/web.EnumOption" + } + }, + "title": { + "type": "string", + "example": "Active Etc" + }, + "value": { + "type": "string", + "example": "active_etc" } } }, @@ -474,13 +1688,87 @@ "fields": { "type": "array", "items": { - "type": "FieldError" + "$ref": "#/definitions/web.FieldError" } }, "status": { "type": "integer" } } + }, + "web.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/web.FieldError" + } + } + } + }, + "web.FieldError": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "field": { + "type": "string" + } + } + }, + "web.TimeResponse": { + "type": "object", + "properties": { + "date": { + "type": "string", + "example": "2019-06-25" + }, + "kitchen": { + "type": "string", + "example": "3:00AM" + }, + "local": { + "type": "string", + "example": "Tue Jun 25 3:00AM" + }, + "local_date": { + "type": "string", + "example": "Tue Jun 25" + }, + "now_rel_time": { + "type": "string", + "example": "15 hours from now" + }, + "now_time": { + "type": "string", + "example": "5 hours ago" + }, + "rfc1123": { + "type": "string", + "example": "Tue, 25 Jun 2019 03:00:53 AKDT" + }, + "time": { + "type": "string", + "example": "03:00:53" + }, + "timezone": { + "type": "string", + "example": "America/Anchorage" + }, + "value": { + "type": "string", + "example": "2019-06-25T03:00:53.284-08:00" + }, + "value_utc": { + "type": "string", + "example": "2019-06-25T11:00:53.284Z" + } + } } }, "securityDefinitions": { @@ -492,9 +1780,8 @@ "flow": "password", "tokenUrl": "/v1/oauth/token", "scopes": { - "admin": " Grants read and write access to administrative information", - "read": " Grants read access", - "write": " Grants write access" + "admin": " Grants administrative privileges with role of admin.", + "user": " Grants basic privileges with role of user." } } } diff --git a/example-project/cmd/web-api/docs/swagger.yaml b/example-project/cmd/web-api/docs/swagger.yaml index 6d4b319..7006701 100644 --- a/example-project/cmd/web-api/docs/swagger.yaml +++ b/example-project/cmd/web-api/docs/swagger.yaml @@ -1,6 +1,6 @@ basePath: '{{.BasePath}}' definitions: - account.Account: + account.AccountResponse: properties: address1: example: 221 Tatitlek Ave @@ -9,8 +9,10 @@ definitions: example: 'Box #1832' type: string archived_at: - type: string + $ref: '#/definitions/web.TimeResponse' + type: object billing_user_id: + example: d69bdef7-173f-4d29-b52c-3edc60baf6a2 type: string city: example: Valdez @@ -19,6 +21,49 @@ definitions: example: USA type: string created_at: + $ref: '#/definitions/web.TimeResponse' + type: object + id: + example: c4653bf9-5978-48b7-89c5-95704aebb7e2 + type: string + name: + example: Company Name + type: string + region: + example: AK + type: string + signup_user_id: + example: d69bdef7-173f-4d29-b52c-3edc60baf6a2 + type: string + status: + $ref: '#/definitions/web.EnumResponse' + type: object + timezone: + example: America/Anchorage + type: string + updated_at: + $ref: '#/definitions/web.TimeResponse' + type: object + zipcode: + example: "99686" + type: string + type: object + account.AccountUpdateRequest: + properties: + address1: + example: 221 Tatitlek Ave + type: string + address2: + example: 'Box #1832' + type: string + billing_user_id: + example: d69bdef7-173f-4d29-b52c-3edc60baf6a2 + type: string + city: + example: Valdez + type: string + country: + example: USA type: string id: example: c4653bf9-5978-48b7-89c5-95704aebb7e2 @@ -30,18 +75,94 @@ definitions: example: AK type: string signup_user_id: + example: d69bdef7-173f-4d29-b52c-3edc60baf6a2 type: string status: - example: active + enum: + - active + - pending + - disabled + example: disabled type: string timezone: example: America/Anchorage type: string - updated_at: - type: string zipcode: example: "99686" type: string + required: + - id + type: object + project.ProjectArchiveRequest: + properties: + id: + example: 985f1746-1d9f-459f-a2d9-fc53ece5ae86 + type: string + required: + - id + type: object + project.ProjectCreateRequest: + properties: + account_id: + example: c4653bf9-5978-48b7-89c5-95704aebb7e2 + type: string + name: + example: Rocket Launch + type: string + status: + enum: + - active + - disabled + example: active + type: string + required: + - account_id + - name + type: object + project.ProjectResponse: + properties: + account_id: + example: c4653bf9-5978-48b7-89c5-95704aebb7e2 + type: string + archived_at: + $ref: '#/definitions/web.TimeResponse' + type: object + created_at: + $ref: '#/definitions/web.TimeResponse' + type: object + id: + example: 985f1746-1d9f-459f-a2d9-fc53ece5ae86 + type: string + name: + example: Rocket Launch + type: string + status: + $ref: '#/definitions/web.EnumResponse' + type: object + updated_at: + $ref: '#/definitions/web.TimeResponse' + type: object + required: + - account_id + - id + - name + type: object + project.ProjectUpdateRequest: + properties: + id: + example: 985f1746-1d9f-459f-a2d9-fc53ece5ae86 + type: string + name: + example: Rocket Launch to Moon + type: string + status: + enum: + - active + - disabled + example: disabled + type: string + required: + - id type: object signup.SignupRequest: properties: @@ -102,11 +223,9 @@ definitions: signup.SignupResponse: properties: account: - $ref: '#/definitions/account.Account' - type: object + type: string user: - $ref: '#/definitions/user.User' - type: object + type: string type: object user.Token: properties: @@ -117,12 +236,44 @@ definitions: token_type: type: string type: object - user.User: + user.UserArchiveRequest: + properties: + id: + example: d69bdef7-173f-4d29-b52c-3edc60baf6a2 + type: string + required: + - id + type: object + user.UserCreateRequest: + properties: + email: + example: gabi@geeksinthewoods.com + type: string + name: + example: Gabi May + type: string + password: + example: SecretString + type: string + password_confirm: + example: SecretString + type: string + timezone: + example: America/Anchorage + type: string + required: + - email + - name + - password + type: object + user.UserResponse: properties: archived_at: - type: string + $ref: '#/definitions/web.TimeResponse' + type: object created_at: - type: string + $ref: '#/definitions/web.TimeResponse' + type: object email: example: gabi@geeksinthewoods.com type: string @@ -136,9 +287,65 @@ definitions: example: America/Anchorage type: string updated_at: + $ref: '#/definitions/web.TimeResponse' + type: object + type: object + user.UserUpdatePasswordRequest: + properties: + id: + example: d69bdef7-173f-4d29-b52c-3edc60baf6a2 + type: string + password: + example: NeverTellSecret + type: string + password_confirm: + example: NeverTellSecret type: string required: - - name + - id + - password + type: object + user.UserUpdateRequest: + properties: + email: + example: gabi.may@geeksinthewoods.com + type: string + id: + example: d69bdef7-173f-4d29-b52c-3edc60baf6a2 + type: string + name: + example: Gabi May Not + type: string + timezone: + example: America/Anchorage + type: string + required: + - id + type: object + web.EnumOption: + properties: + selected: + example: true + type: boolean + title: + example: Active Etc + type: string + value: + example: active_etc + type: string + type: object + web.EnumResponse: + properties: + options: + items: + $ref: '#/definitions/web.EnumOption' + type: array + title: + example: Active Etc + type: string + value: + example: active_etc + type: string type: object web.Error: properties: @@ -146,11 +353,63 @@ definitions: type: error fields: items: - type: FieldError + $ref: '#/definitions/web.FieldError' type: array status: type: integer type: object + web.ErrorResponse: + properties: + error: + type: string + fields: + items: + $ref: '#/definitions/web.FieldError' + type: array + type: object + web.FieldError: + properties: + error: + type: string + field: + type: string + type: object + web.TimeResponse: + properties: + date: + example: "2019-06-25" + type: string + kitchen: + example: 3:00AM + type: string + local: + example: Tue Jun 25 3:00AM + type: string + local_date: + example: Tue Jun 25 + type: string + now_rel_time: + example: 15 hours from now + type: string + now_time: + example: 5 hours ago + type: string + rfc1123: + example: Tue, 25 Jun 2019 03:00:53 AKDT + type: string + time: + example: "03:00:53" + type: string + timezone: + example: America/Anchorage + type: string + value: + example: "2019-06-25T03:00:53.284-08:00" + type: string + value_utc: + example: "2019-06-25T11:00:53.284Z" + type: string + type: object host: '{{.Host}}' info: contact: @@ -165,11 +424,53 @@ info: title: SaaS Example API version: '{{.Version}}' paths: + /accounts: + patch: + consumes: + - application/json + description: Update updates the specified account in the system. + parameters: + - description: Update fields + in: body + name: data + required: true + schema: + $ref: '#/definitions/account.AccountUpdateRequest' + type: object + produces: + - application/json + responses: + "201": {} + "400": + description: Bad Request + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "403": + description: Forbidden + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + security: + - OAuth2Password: [] + summary: Update account by ID + tags: + - account /accounts/{id}: get: consumes: - application/json - description: get string by ID + description: Read returns the specified account from the system. parameters: - description: Account ID in: path @@ -181,31 +482,32 @@ paths: responses: "200": description: OK - headers: - Token: - description: qwerty - type: string schema: - $ref: '#/definitions/account.Account' + $ref: '#/definitions/account.AccountResponse' type: object "400": description: Bad Request schema: - $ref: '#/definitions/web.Error' + $ref: '#/definitions/web.ErrorResponse' type: object "403": description: Forbidden schema: - $ref: '#/definitions/web.Error' + $ref: '#/definitions/web.ErrorResponse' type: object "404": description: Not Found schema: - $ref: '#/definitions/web.Error' + $ref: '#/definitions/web.ErrorResponse' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/web.ErrorResponse' type: object security: - OAuth2Password: [] - summary: Read returns the specified account from the system. + summary: Get account by ID tags: - account /oauth/token: @@ -214,6 +516,14 @@ paths: - application/json description: Token generates an oauth2 accessToken using Basic Auth with a user's email and password. + parameters: + - description: Scope + enum: + - user + - admin + in: query + name: scope + type: string produces: - application/json responses: @@ -246,6 +556,273 @@ paths: summary: Token handles a request to authenticate a user. tags: - user + /project: + get: + consumes: + - application/json + description: Find returns the existing projects in the system. + parameters: + - description: 'Filter string, example: name = ''Moon Launch''' + in: query + name: where + type: string + - description: 'Order columns separated by comma, example: created_at desc' + in: query + name: order + type: string + - description: 'Limit, example: 10' + in: query + name: limit + type: integer + - description: 'Offset, example: 20' + in: query + name: offset + type: integer + - description: 'Included Archived, example: false' + in: query + name: included-archived + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/project.ProjectResponse' + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "403": + description: Forbidden + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + security: + - OAuth2Password: [] + summary: List projects + tags: + - project + /projects: + patch: + consumes: + - application/json + description: Update updates the specified project in the system. + parameters: + - description: Update fields + in: body + name: data + required: true + schema: + $ref: '#/definitions/project.ProjectUpdateRequest' + type: object + produces: + - application/json + responses: + "201": {} + "400": + description: Bad Request + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "403": + description: Forbidden + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + security: + - OAuth2Password: [] + summary: Update project by ID + tags: + - project + post: + consumes: + - application/json + description: Create inserts a new project into the system. + parameters: + - description: Project details + in: body + name: data + required: true + schema: + $ref: '#/definitions/project.ProjectCreateRequest' + type: object + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/project.ProjectResponse' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "403": + description: Forbidden + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + security: + - OAuth2Password: [] + summary: Create new project. + tags: + - project + /projects/{id}: + delete: + consumes: + - application/json + description: Delete removes the specified project from the system. + parameters: + - description: Project ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "201": {} + "400": + description: Bad Request + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "403": + description: Forbidden + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + security: + - OAuth2Password: [] + summary: Delete project by ID + tags: + - project + get: + consumes: + - application/json + description: Read returns the specified project from the system. + parameters: + - description: Project ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/project.ProjectResponse' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "403": + description: Forbidden + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + security: + - OAuth2Password: [] + summary: Get project by ID. + tags: + - project + /projects/archive: + patch: + consumes: + - application/json + description: Archive soft-deletes the specified project from the system. + parameters: + - description: Update fields + in: body + name: data + required: true + schema: + $ref: '#/definitions/project.ProjectArchiveRequest' + type: object + produces: + - application/json + responses: + "201": {} + "400": + description: Bad Request + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "403": + description: Forbidden + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + security: + - OAuth2Password: [] + summary: Archive project by ID + tags: + - project /signup: post: consumes: @@ -264,40 +841,217 @@ paths: responses: "200": description: OK - headers: - Token: - description: qwerty - type: string schema: $ref: '#/definitions/signup.SignupResponse' type: object "400": description: Bad Request schema: - $ref: '#/definitions/web.Error' + $ref: '#/definitions/web.ErrorResponse' type: object "403": description: Forbidden schema: - $ref: '#/definitions/web.Error' + $ref: '#/definitions/web.ErrorResponse' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/web.ErrorResponse' type: object summary: Signup handles new account creation. tags: - signup - /users/{id}: + /users: get: consumes: - application/json - description: get string by ID + description: Find returns the existing users in the system. + parameters: + - description: 'Filter string, example: name = ''Company Name'' and email = + ''gabi.may@geeksinthewoods.com''' + in: query + name: where + type: string + - description: 'Order columns separated by comma, example: created_at desc' + in: query + name: order + type: string + - description: 'Limit, example: 10' + in: query + name: limit + type: integer + - description: 'Offset, example: 20' + in: query + name: offset + type: integer + - description: 'Included Archived, example: false' + in: query + name: included-archived + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/user.UserResponse' + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "403": + description: Forbidden + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + security: + - OAuth2Password: [] + summary: List users + tags: + - user + patch: + consumes: + - application/json + description: Update updates the specified user in the system. + parameters: + - description: Update fields + in: body + name: data + required: true + schema: + $ref: '#/definitions/user.UserUpdateRequest' + type: object + produces: + - application/json + responses: + "201": {} + "400": + description: Bad Request + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "403": + description: Forbidden + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + security: + - OAuth2Password: [] + summary: Update user by ID + tags: + - user + post: + consumes: + - application/json + description: Create inserts a new user into the system. + parameters: + - description: User details + in: body + name: data + required: true + schema: + $ref: '#/definitions/user.UserCreateRequest' + type: object + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/user.UserResponse' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "403": + description: Forbidden + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + security: + - OAuth2Password: [] + summary: Create new user. + tags: + - user + /users/{id}: + delete: + consumes: + - application/json + description: Delete removes the specified user from the system. parameters: - description: User ID in: path name: id required: true type: string - - description: Authentication header - in: header - name: Authorization + produces: + - application/json + responses: + "201": {} + "400": + description: Bad Request + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "403": + description: Forbidden + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + security: + - OAuth2Password: [] + summary: Delete user by ID + tags: + - user + get: + consumes: + - application/json + description: Read returns the specified user from the system. + parameters: + - description: User ID + in: path + name: id required: true type: string produces: @@ -305,31 +1059,156 @@ paths: responses: "200": description: OK - headers: - Token: - description: qwerty - type: string schema: - $ref: '#/definitions/user.User' + $ref: '#/definitions/user.UserResponse' type: object "400": description: Bad Request schema: - $ref: '#/definitions/web.Error' + $ref: '#/definitions/web.ErrorResponse' type: object "403": description: Forbidden schema: - $ref: '#/definitions/web.Error' + $ref: '#/definitions/web.ErrorResponse' type: object "404": description: Not Found schema: - $ref: '#/definitions/web.Error' + $ref: '#/definitions/web.ErrorResponse' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/web.ErrorResponse' type: object security: - OAuth2Password: [] - summary: Read returns the specified user from the system. + summary: Get user by ID + tags: + - user + /users/archive: + patch: + consumes: + - application/json + description: Archive soft-deletes the specified user from the system. + parameters: + - description: Update fields + in: body + name: data + required: true + schema: + $ref: '#/definitions/user.UserArchiveRequest' + type: object + produces: + - application/json + responses: + "201": {} + "400": + description: Bad Request + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "403": + description: Forbidden + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + security: + - OAuth2Password: [] + summary: Archive user by ID + tags: + - user + /users/password: + patch: + consumes: + - application/json + description: Update updates the password for a specified user in the system. + parameters: + - description: Update fields + in: body + name: data + required: true + schema: + $ref: '#/definitions/user.UserUpdatePasswordRequest' + type: object + produces: + - application/json + responses: + "201": {} + "400": + description: Bad Request + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "403": + description: Forbidden + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + security: + - OAuth2Password: [] + summary: Update user password by ID + tags: + - user + /users/switch-account/{account_id}: + patch: + consumes: + - application/json + description: SwitchAccount updates the auth claims to a new account. + parameters: + - description: Account ID + in: path + name: account_id + required: true + type: integer + produces: + - application/json + responses: + "201": {} + "400": + description: Bad Request + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "403": + description: Forbidden + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/web.ErrorResponse' + type: object + security: + - OAuth2Password: [] + summary: Switch account. tags: - user securityDefinitions: @@ -338,9 +1217,8 @@ securityDefinitions: OAuth2Password: flow: password scopes: - admin: ' Grants read and write access to administrative information' - read: ' Grants read access' - write: ' Grants write access' + admin: ' Grants administrative privileges with role of admin.' + user: ' Grants basic privileges with role of user.' tokenUrl: /v1/oauth/token type: oauth2 swagger: "2.0" diff --git a/example-project/cmd/web-api/handlers/account.go b/example-project/cmd/web-api/handlers/account.go index 25fce9e..f985dd3 100644 --- a/example-project/cmd/web-api/handlers/account.go +++ b/example-project/cmd/web-api/handlers/account.go @@ -10,6 +10,7 @@ import ( "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web" "github.com/jmoiron/sqlx" "github.com/pkg/errors" + "gopkg.in/go-playground/validator.v9" ) // Account represents the Account API method handler set. @@ -19,39 +20,19 @@ type Account struct { // ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE. } -// List returns all the existing accounts in the system. -func (a *Account) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { - claims, ok := ctx.Value(auth.Key).(auth.Claims) - if !ok { - return errors.New("claims missing from context") - } - - var req account.AccountFindRequest - if err := web.Decode(r, &req); err != nil { - return errors.Wrap(err, "") - } - - res, err := account.Find(ctx, claims, a.MasterDB, req) - if err != nil { - return err - } - - return web.RespondJson(ctx, w, res, http.StatusOK) -} - // Read godoc -// @Summary Read returns the specified account from the system. -// @Description get string by ID +// @Summary Get account by ID +// @Description Read returns the specified account from the system. // @Tags account // @Accept json // @Produce json // @Security OAuth2Password // @Param id path string true "Account ID" -// @Success 200 {object} account.Account -// @Header 200 {string} Token "qwerty" -// @Failure 400 {object} web.Error -// @Failure 403 {object} web.Error -// @Failure 404 {object} web.Error +// @Success 200 {object} account.AccountResponse +// @Failure 400 {object} web.ErrorResponse +// @Failure 403 {object} web.ErrorResponse +// @Failure 404 {object} web.ErrorResponse +// @Failure 500 {object} web.ErrorResponse // @Router /accounts/{id} [get] func (a *Account) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, ok := ctx.Value(auth.Key).(auth.Claims) @@ -82,40 +63,23 @@ func (a *Account) Read(ctx context.Context, w http.ResponseWriter, r *http.Reque } } - return web.RespondJson(ctx, w, res, http.StatusOK) + return web.RespondJson(ctx, w, res.Response(ctx), http.StatusOK) } -// Create inserts a new account into the system. -func (a *Account) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { - v, ok := ctx.Value(web.KeyValues).(*web.Values) - if !ok { - return web.NewShutdownError("web value missing from context") - } - - claims, ok := ctx.Value(auth.Key).(auth.Claims) - if !ok { - return errors.New("claims missing from context") - } - - var req account.AccountCreateRequest - if err := web.Decode(r, &req); err != nil { - return errors.Wrap(err, "") - } - - res, err := account.Create(ctx, claims, a.MasterDB, req, v.Now) - if err != nil { - switch err { - case account.ErrForbidden: - return web.NewRequestError(err, http.StatusForbidden) - default: - return errors.Wrapf(err, "User: %+v", &req) - } - } - - return web.RespondJson(ctx, w, res, http.StatusCreated) -} - -// Update updates the specified account in the system. +// Read godoc +// @Summary Update account by ID +// @Description Update updates the specified account in the system. +// @Tags account +// @Accept json +// @Produce json +// @Security OAuth2Password +// @Param data body account.AccountUpdateRequest true "Update fields" +// @Success 201 +// @Failure 400 {object} web.ErrorResponse +// @Failure 403 {object} web.ErrorResponse +// @Failure 404 {object} web.ErrorResponse +// @Failure 500 {object} web.ErrorResponse +// @Router /accounts [patch] func (a *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, ok := ctx.Value(web.KeyValues).(*web.Values) if !ok { @@ -129,9 +93,14 @@ func (a *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Req var req account.AccountUpdateRequest if err := web.Decode(r, &req); err != nil { - return errors.Wrap(err, "") + err = errors.WithStack(err) + + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } + return err } - req.ID = params["id"] err := account.Update(ctx, claims, a.MasterDB, req, v.Now) if err != nil { @@ -143,62 +112,14 @@ func (a *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Req case account.ErrForbidden: return web.NewRequestError(err, http.StatusForbidden) default: + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } + return errors.Wrapf(err, "Id: %s Account: %+v", params["id"], &req) } } return web.RespondJson(ctx, w, nil, http.StatusNoContent) } - -// Archive soft-deletes the specified account from the system. -func (a *Account) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { - v, ok := ctx.Value(web.KeyValues).(*web.Values) - if !ok { - return web.NewShutdownError("web value missing from context") - } - - claims, ok := ctx.Value(auth.Key).(auth.Claims) - if !ok { - return errors.New("claims missing from context") - } - - err := account.Archive(ctx, claims, a.MasterDB, params["id"], v.Now) - if err != nil { - switch err { - case account.ErrInvalidID: - return web.NewRequestError(err, http.StatusBadRequest) - case account.ErrNotFound: - return web.NewRequestError(err, http.StatusNotFound) - case account.ErrForbidden: - return web.NewRequestError(err, http.StatusForbidden) - default: - return errors.Wrapf(err, "Id: %s", params["id"]) - } - } - - return web.RespondJson(ctx, w, nil, http.StatusNoContent) -} - -// Delete removes the specified account from the system. -func (a *Account) Delete(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { - claims, ok := ctx.Value(auth.Key).(auth.Claims) - if !ok { - return errors.New("claims missing from context") - } - - err := account.Delete(ctx, claims, a.MasterDB, params["id"]) - if err != nil { - switch err { - case account.ErrInvalidID: - return web.NewRequestError(err, http.StatusBadRequest) - case account.ErrNotFound: - return web.NewRequestError(err, http.StatusNotFound) - case account.ErrForbidden: - return web.NewRequestError(err, http.StatusForbidden) - default: - return errors.Wrapf(err, "Id: %s", params["id"]) - } - } - - return web.RespondJson(ctx, w, nil, http.StatusNoContent) -} diff --git a/example-project/cmd/web-api/handlers/project.go b/example-project/cmd/web-api/handlers/project.go index 18accb3..7abfa8c 100644 --- a/example-project/cmd/web-api/handlers/project.go +++ b/example-project/cmd/web-api/handlers/project.go @@ -4,12 +4,14 @@ import ( "context" "net/http" "strconv" + "strings" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/project" "github.com/jmoiron/sqlx" "github.com/pkg/errors" + "gopkg.in/go-playground/validator.v9" ) // Project represents the Project API method handler set. @@ -19,7 +21,23 @@ type Project struct { // ADD OTHER STATE LIKE THE LOGGER IF NEEDED. } -// List returns all the existing projects in the system. +// Find godoc +// @Summary List projects +// @Description Find returns the existing projects in the system. +// @Tags project +// @Accept json +// @Produce json +// @Security OAuth2Password +// @Param where query string false "Filter string, example: name = 'Moon Launch'" +// @Param order query string false "Order columns separated by comma, example: created_at desc" +// @Param limit query integer false "Limit, example: 10" +// @Param offset query integer false "Offset, example: 20" +// @Param included-archived query boolean false "Included Archived, example: false" +// @Success 200 {array} project.ProjectResponse +// @Failure 400 {object} web.ErrorResponse +// @Failure 403 {object} web.ErrorResponse +// @Failure 500 {object} web.ErrorResponse +// @Router /project [get] func (p *Project) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, ok := ctx.Value(auth.Key).(auth.Claims) if !ok { @@ -27,8 +45,57 @@ func (p *Project) Find(ctx context.Context, w http.ResponseWriter, r *http.Reque } var req project.ProjectFindRequest - if err := web.Decode(r, &req); err != nil { - return errors.Wrap(err, "") + + // Handle where query value if set. + if v := r.URL.Query().Get("where"); v != "" { + where, args, err := web.ExtractWhereArgs(v) + if err != nil { + return web.NewRequestError(err, http.StatusBadRequest) + } + req.Where = &where + req.Args = args + } + + // Handle order query value if set. + if v := r.URL.Query().Get("order"); v != "" { + for _, o := range strings.Split(v, ",") { + o = strings.TrimSpace(o) + if o != "" { + req.Order = append(req.Order, o) + } + } + } + + // Handle limit query value if set. + if v := r.URL.Query().Get("limit"); v != "" { + l, err := strconv.Atoi(v) + if err != nil { + err = errors.WithMessagef(err, "unable to parse %s as int for limit param", v) + return web.NewRequestError(err, http.StatusBadRequest) + } + ul := uint(l) + req.Limit = &ul + } + + // Handle offset query value if set. + if v := r.URL.Query().Get("offset"); v != "" { + l, err := strconv.Atoi(v) + if err != nil { + err = errors.WithMessagef(err, "unable to parse %s as int for offset param", v) + return web.NewRequestError(err, http.StatusBadRequest) + } + ul := uint(l) + req.Limit = &ul + } + + // Handle order query value if set. + if v := r.URL.Query().Get("included-archived"); v != "" { + b, err := strconv.ParseBool(v) + if err != nil { + err = errors.WithMessagef(err, "unable to parse %s as boolean for included-archived param", v) + return web.NewRequestError(err, http.StatusBadRequest) + } + req.IncludedArchived = b } res, err := project.Find(ctx, claims, p.MasterDB, req) @@ -36,10 +103,28 @@ func (p *Project) Find(ctx context.Context, w http.ResponseWriter, r *http.Reque return err } - return web.RespondJson(ctx, w, res, http.StatusOK) + var resp []*project.ProjectResponse + for _, m := range res { + resp = append(resp, m.Response(ctx)) + } + + return web.RespondJson(ctx, w, resp, http.StatusOK) } -// Read returns the specified project from the system. +// Read godoc +// @Summary Get project by ID. +// @Description Read returns the specified project from the system. +// @Tags project +// @Accept json +// @Produce json +// @Security OAuth2Password +// @Param id path string true "Project ID" +// @Success 200 {object} project.ProjectResponse +// @Failure 400 {object} web.ErrorResponse +// @Failure 403 {object} web.ErrorResponse +// @Failure 404 {object} web.ErrorResponse +// @Failure 500 {object} web.ErrorResponse +// @Router /projects/{id} [get] func (p *Project) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, ok := ctx.Value(auth.Key).(auth.Claims) if !ok { @@ -69,10 +154,23 @@ func (p *Project) Read(ctx context.Context, w http.ResponseWriter, r *http.Reque } } - return web.RespondJson(ctx, w, res, http.StatusOK) + return web.RespondJson(ctx, w, res.Response(ctx), http.StatusOK) } -// Create inserts a new project into the system. +// Create godoc +// @Summary Create new project. +// @Description Create inserts a new project into the system. +// @Tags project +// @Accept json +// @Produce json +// @Security OAuth2Password +// @Param data body project.ProjectCreateRequest true "Project details" +// @Success 200 {object} project.ProjectResponse +// @Failure 400 {object} web.ErrorResponse +// @Failure 403 {object} web.ErrorResponse +// @Failure 404 {object} web.ErrorResponse +// @Failure 500 {object} web.ErrorResponse +// @Router /projects [post] func (p *Project) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, ok := ctx.Value(web.KeyValues).(*web.Values) if !ok { @@ -86,7 +184,13 @@ func (p *Project) Create(ctx context.Context, w http.ResponseWriter, r *http.Req var req project.ProjectCreateRequest if err := web.Decode(r, &req); err != nil { - return errors.Wrap(err, "") + err = errors.WithStack(err) + + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } + return err } res, err := project.Create(ctx, claims, p.MasterDB, req, v.Now) @@ -95,14 +199,31 @@ func (p *Project) Create(ctx context.Context, w http.ResponseWriter, r *http.Req case project.ErrForbidden: return web.NewRequestError(err, http.StatusForbidden) default: + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } return errors.Wrapf(err, "Project: %+v", &req) } } - return web.RespondJson(ctx, w, res, http.StatusCreated) + return web.RespondJson(ctx, w, res.Response(ctx), http.StatusCreated) } -// Update updates the specified project in the system. +// Read godoc +// @Summary Update project by ID +// @Description Update updates the specified project in the system. +// @Tags project +// @Accept json +// @Produce json +// @Security OAuth2Password +// @Param data body project.ProjectUpdateRequest true "Update fields" +// @Success 201 +// @Failure 400 {object} web.ErrorResponse +// @Failure 403 {object} web.ErrorResponse +// @Failure 404 {object} web.ErrorResponse +// @Failure 500 {object} web.ErrorResponse +// @Router /projects [patch] func (p *Project) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, ok := ctx.Value(web.KeyValues).(*web.Values) if !ok { @@ -116,9 +237,14 @@ func (p *Project) Update(ctx context.Context, w http.ResponseWriter, r *http.Req var req project.ProjectUpdateRequest if err := web.Decode(r, &req); err != nil { - return errors.Wrap(err, "") + err = errors.WithStack(err) + + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } + return err } - req.ID = params["id"] err := project.Update(ctx, claims, p.MasterDB, req, v.Now) if err != nil { @@ -130,14 +256,31 @@ func (p *Project) Update(ctx context.Context, w http.ResponseWriter, r *http.Req case project.ErrForbidden: return web.NewRequestError(err, http.StatusForbidden) default: - return errors.Wrapf(err, "ID: %s Update: %+v", params["id"], req) + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } + return errors.Wrapf(err, "ID: %s Update: %+v", req.ID, req) } } return web.RespondJson(ctx, w, nil, http.StatusNoContent) } -// Archive soft-deletes the specified project from the system. +// Read godoc +// @Summary Archive project by ID +// @Description Archive soft-deletes the specified project from the system. +// @Tags project +// @Accept json +// @Produce json +// @Security OAuth2Password +// @Param data body project.ProjectArchiveRequest true "Update fields" +// @Success 201 +// @Failure 400 {object} web.ErrorResponse +// @Failure 403 {object} web.ErrorResponse +// @Failure 404 {object} web.ErrorResponse +// @Failure 500 {object} web.ErrorResponse +// @Router /projects/archive [patch] func (p *Project) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, ok := ctx.Value(web.KeyValues).(*web.Values) if !ok { @@ -149,7 +292,18 @@ func (p *Project) Archive(ctx context.Context, w http.ResponseWriter, r *http.Re return errors.New("claims missing from context") } - err := project.Archive(ctx, claims, p.MasterDB, params["id"], v.Now) + var req project.ProjectArchiveRequest + if err := web.Decode(r, &req); err != nil { + err = errors.WithStack(err) + + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } + return err + } + + err := project.Archive(ctx, claims, p.MasterDB, req, v.Now) if err != nil { switch err { case project.ErrInvalidID: @@ -159,14 +313,32 @@ func (p *Project) Archive(ctx context.Context, w http.ResponseWriter, r *http.Re case project.ErrForbidden: return web.NewRequestError(err, http.StatusForbidden) default: - return errors.Wrapf(err, "Id: %s", params["id"]) + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } + + return errors.Wrapf(err, "Id: %s", req.ID) } } return web.RespondJson(ctx, w, nil, http.StatusNoContent) } -// Delete removes the specified project from the system. +// Delete godoc +// @Summary Delete project by ID +// @Description Delete removes the specified project from the system. +// @Tags project +// @Accept json +// @Produce json +// @Security OAuth2Password +// @Param id path string true "Project ID" +// @Success 201 +// @Failure 400 {object} web.ErrorResponse +// @Failure 403 {object} web.ErrorResponse +// @Failure 404 {object} web.ErrorResponse +// @Failure 500 {object} web.ErrorResponse +// @Router /projects/{id} [delete] func (p *Project) Delete(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, ok := ctx.Value(auth.Key).(auth.Claims) if !ok { diff --git a/example-project/cmd/web-api/handlers/routes.go b/example-project/cmd/web-api/handlers/routes.go index 295cb78..80b9f23 100644 --- a/example-project/cmd/web-api/handlers/routes.go +++ b/example-project/cmd/web-api/handlers/routes.go @@ -30,14 +30,15 @@ func API(shutdown chan os.Signal, log *log.Logger, masterDB *sqlx.DB, redis *red MasterDB: masterDB, TokenGenerator: authenticator, } + app.Handle("GET", "/v1/users", u.Find, mid.Authenticate(authenticator)) app.Handle("POST", "/v1/users", u.Create, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) app.Handle("GET", "/v1/users/:id", u.Read, mid.Authenticate(authenticator)) - app.Handle("PATCH", "/v1/users/:id", u.Update, mid.Authenticate(authenticator)) - app.Handle("PATCH", "/v1/users/:id/password", u.UpdatePassword, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("PATCH", "/v1/users/:id/archive", u.Archive, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("PATCH", "/v1/users", u.Update, mid.Authenticate(authenticator)) + app.Handle("PATCH", "/v1/users/password", u.UpdatePassword, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("PATCH", "/v1/users/archive", u.Archive, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) app.Handle("DELETE", "/v1/users/:id", u.Delete, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("PATCH", "/v1/users/switch-account/:accountId", u.SwitchAccount, mid.Authenticate(authenticator)) + app.Handle("PATCH", "/v1/users/switch-account/:account_id", u.SwitchAccount, mid.Authenticate(authenticator)) // This route is not authenticated app.Handle("POST", "/v1/oauth/token", u.Token) @@ -46,12 +47,8 @@ func API(shutdown chan os.Signal, log *log.Logger, masterDB *sqlx.DB, redis *red a := Account{ MasterDB: masterDB, } - app.Handle("GET", "/v1/accounts", a.Find, mid.Authenticate(authenticator)) - app.Handle("POST", "/v1/accounts", a.Create, mid.Authenticate(authenticator)) app.Handle("GET", "/v1/accounts/:id", a.Read, mid.Authenticate(authenticator)) - app.Handle("PATCH", "/v1/accounts/:id", a.Update, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("PATCH", "/v1/accounts/:id/archive", a.Archive, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("DELETE", "/v1/accounts/:id", a.Delete, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("PATCH", "/v1/accounts", a.Update, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) // Register signup endpoints. s := Signup{ @@ -66,8 +63,8 @@ func API(shutdown chan os.Signal, log *log.Logger, masterDB *sqlx.DB, redis *red app.Handle("GET", "/v1/projects", p.Find, mid.Authenticate(authenticator)) app.Handle("POST", "/v1/projects", p.Create, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) app.Handle("GET", "/v1/projects/:id", p.Read, mid.Authenticate(authenticator)) - app.Handle("PATCH", "/v1/projects/:id", p.Update, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("PATCH", "/v1/projects/:id/archive", p.Archive, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("PATCH", "/v1/projects", p.Update, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("PATCH", "/v1/projects/archive", p.Archive, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) app.Handle("DELETE", "/v1/projects/:id", p.Delete, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) // Register swagger documentation. @@ -78,3 +75,12 @@ func API(shutdown chan os.Signal, log *log.Logger, masterDB *sqlx.DB, redis *red return app } + +// Types godoc +// @Summary List of types. +// @Param data body web.FieldError false "Field Error" +// @Param data body web.TimeResponse false "Time Response" +// @Param data body web.EnumResponse false "Enum Response" +// @Param data body web.EnumOption false "Enum Option" +// To support nested types not parsed by swag. +func Types() {} diff --git a/example-project/cmd/web-api/handlers/signup.go b/example-project/cmd/web-api/handlers/signup.go index 6265741..5187676 100644 --- a/example-project/cmd/web-api/handlers/signup.go +++ b/example-project/cmd/web-api/handlers/signup.go @@ -2,13 +2,15 @@ package handlers import ( "context" + "net/http" + "geeks-accelerator/oss/saas-starter-kit/example-project/internal/account" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/signup" "github.com/jmoiron/sqlx" "github.com/pkg/errors" - "net/http" + "gopkg.in/go-playground/validator.v9" ) // Signup represents the Signup API method handler set. @@ -26,9 +28,9 @@ type Signup struct { // @Produce json // @Param data body signup.SignupRequest true "Signup details" // @Success 200 {object} signup.SignupResponse -// @Header 200 {string} Token "qwerty" -// @Failure 400 {object} web.Error -// @Failure 403 {object} web.Error +// @Failure 400 {object} web.ErrorResponse +// @Failure 403 {object} web.ErrorResponse +// @Failure 500 {object} web.ErrorResponse // @Router /signup [post] func (c *Signup) Signup(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, ok := ctx.Value(web.KeyValues).(*web.Values) @@ -41,7 +43,13 @@ func (c *Signup) Signup(ctx context.Context, w http.ResponseWriter, r *http.Requ var req signup.SignupRequest if err := web.Decode(r, &req); err != nil { - return errors.Wrap(err, "") + err = errors.WithStack(err) + + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } + return err } res, err := signup.Signup(ctx, claims, c.MasterDB, req, v.Now) @@ -50,7 +58,12 @@ func (c *Signup) Signup(ctx context.Context, w http.ResponseWriter, r *http.Requ case account.ErrForbidden: return web.NewRequestError(err, http.StatusForbidden) default: - return errors.Wrapf(err, "User: %+v", &req) + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } + + return errors.Wrapf(err, "Signup: %+v", &req) } } diff --git a/example-project/cmd/web-api/handlers/user.go b/example-project/cmd/web-api/handlers/user.go index c1f8ca7..4b9c998 100644 --- a/example-project/cmd/web-api/handlers/user.go +++ b/example-project/cmd/web-api/handlers/user.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "strconv" + "strings" "time" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth" @@ -11,6 +12,7 @@ import ( "geeks-accelerator/oss/saas-starter-kit/example-project/internal/user" "github.com/jmoiron/sqlx" "github.com/pkg/errors" + "gopkg.in/go-playground/validator.v9" ) // sessionTtl defines the auth token expiration. @@ -24,7 +26,23 @@ type User struct { // ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE. } -// List returns all the existing users in the system. +// Find godoc +// @Summary List users +// @Description Find returns the existing users in the system. +// @Tags user +// @Accept json +// @Produce json +// @Security OAuth2Password +// @Param where query string false "Filter string, example: name = 'Company Name' and email = 'gabi.may@geeksinthewoods.com'" +// @Param order query string false "Order columns separated by comma, example: created_at desc" +// @Param limit query integer false "Limit, example: 10" +// @Param offset query integer false "Offset, example: 20" +// @Param included-archived query boolean false "Included Archived, example: false" +// @Success 200 {array} user.UserResponse +// @Failure 400 {object} web.ErrorResponse +// @Failure 403 {object} web.ErrorResponse +// @Failure 500 {object} web.ErrorResponse +// @Router /users [get] func (u *User) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, ok := ctx.Value(auth.Key).(auth.Claims) if !ok { @@ -32,8 +50,67 @@ func (u *User) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, } var req user.UserFindRequest + + // Handle where query value if set. + if v := r.URL.Query().Get("where"); v != "" { + where, args, err := web.ExtractWhereArgs(v) + if err != nil { + return web.NewRequestError(err, http.StatusBadRequest) + } + req.Where = &where + req.Args = args + } + + // Handle order query value if set. + if v := r.URL.Query().Get("order"); v != "" { + for _, o := range strings.Split(v, ",") { + o = strings.TrimSpace(o) + if o != "" { + req.Order = append(req.Order, o) + } + } + } + + // Handle limit query value if set. + if v := r.URL.Query().Get("limit"); v != "" { + l, err := strconv.Atoi(v) + if err != nil { + err = errors.WithMessagef(err, "unable to parse %s as int for limit param", v) + return web.NewRequestError(err, http.StatusBadRequest) + } + ul := uint(l) + req.Limit = &ul + } + + // Handle offset query value if set. + if v := r.URL.Query().Get("offset"); v != "" { + l, err := strconv.Atoi(v) + if err != nil { + err = errors.WithMessagef(err, "unable to parse %s as int for offset param", v) + return web.NewRequestError(err, http.StatusBadRequest) + } + ul := uint(l) + req.Limit = &ul + } + + // Handle order query value if set. + if v := r.URL.Query().Get("included-archived"); v != "" { + b, err := strconv.ParseBool(v) + if err != nil { + err = errors.WithMessagef(err, "unable to parse %s as boolean for included-archived param", v) + return web.NewRequestError(err, http.StatusBadRequest) + } + req.IncludedArchived = b + } + if err := web.Decode(r, &req); err != nil { - return errors.Wrap(err, "") + err = errors.WithStack(err) + + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } + return err } res, err := user.Find(ctx, claims, u.MasterDB, req) @@ -41,22 +118,27 @@ func (u *User) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, return err } - return web.RespondJson(ctx, w, res, http.StatusOK) + var resp []*user.UserResponse + for _, m := range res { + resp = append(resp, m.Response(ctx)) + } + + return web.RespondJson(ctx, w, resp, http.StatusOK) } // Read godoc -// @Summary Read returns the specified user from the system. -// @Description get string by ID +// @Summary Get user by ID +// @Description Read returns the specified user from the system. // @Tags user // @Accept json // @Produce json // @Security OAuth2Password // @Param id path string true "User ID" -// @Success 200 {object} user.User -// @Header 200 {string} Token "qwerty" -// @Failure 400 {object} web.Error -// @Failure 403 {object} web.Error -// @Failure 404 {object} web.Error +// @Success 200 {object} user.UserResponse +// @Failure 400 {object} web.ErrorResponse +// @Failure 403 {object} web.ErrorResponse +// @Failure 404 {object} web.ErrorResponse +// @Failure 500 {object} web.ErrorResponse // @Router /users/{id} [get] func (u *User) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, ok := ctx.Value(auth.Key).(auth.Claims) @@ -83,14 +165,32 @@ func (u *User) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, case user.ErrForbidden: return web.NewRequestError(err, http.StatusForbidden) default: + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } + return errors.Wrapf(err, "ID: %s", params["id"]) } } - return web.RespondJson(ctx, w, res, http.StatusOK) + return web.RespondJson(ctx, w, res.Response(ctx), http.StatusOK) } -// Create inserts a new user into the system. +// Create godoc +// @Summary Create new user. +// @Description Create inserts a new user into the system. +// @Tags user +// @Accept json +// @Produce json +// @Security OAuth2Password +// @Param data body user.UserCreateRequest true "User details" +// @Success 200 {object} user.UserResponse +// @Failure 400 {object} web.ErrorResponse +// @Failure 403 {object} web.ErrorResponse +// @Failure 404 {object} web.ErrorResponse +// @Failure 500 {object} web.ErrorResponse +// @Router /users [post] func (u *User) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, ok := ctx.Value(web.KeyValues).(*web.Values) if !ok { @@ -104,7 +204,13 @@ func (u *User) Create(ctx context.Context, w http.ResponseWriter, r *http.Reques var req user.UserCreateRequest if err := web.Decode(r, &req); err != nil { - return errors.Wrap(err, "") + err = errors.WithStack(err) + + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } + return err } res, err := user.Create(ctx, claims, u.MasterDB, req, v.Now) @@ -113,14 +219,32 @@ func (u *User) Create(ctx context.Context, w http.ResponseWriter, r *http.Reques case user.ErrForbidden: return web.NewRequestError(err, http.StatusForbidden) default: + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } + return errors.Wrapf(err, "User: %+v", &req) } } - return web.RespondJson(ctx, w, res, http.StatusCreated) + return web.RespondJson(ctx, w, res.Response(ctx), http.StatusCreated) } -// Update updates the specified user in the system. +// Read godoc +// @Summary Update user by ID +// @Description Update updates the specified user in the system. +// @Tags user +// @Accept json +// @Produce json +// @Security OAuth2Password +// @Param data body user.UserUpdateRequest true "Update fields" +// @Success 201 +// @Failure 400 {object} web.ErrorResponse +// @Failure 403 {object} web.ErrorResponse +// @Failure 404 {object} web.ErrorResponse +// @Failure 500 {object} web.ErrorResponse +// @Router /users [patch] func (u *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, ok := ctx.Value(web.KeyValues).(*web.Values) if !ok { @@ -134,9 +258,14 @@ func (u *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Reques var req user.UserUpdateRequest if err := web.Decode(r, &req); err != nil { - return errors.Wrap(err, "") + err = errors.WithStack(err) + + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } + return err } - req.ID = params["id"] err := user.Update(ctx, claims, u.MasterDB, req, v.Now) if err != nil { @@ -148,14 +277,32 @@ func (u *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Reques case user.ErrForbidden: return web.NewRequestError(err, http.StatusForbidden) default: - return errors.Wrapf(err, "Id: %s User: %+v", params["id"], &req) + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } + + return errors.Wrapf(err, "Id: %s User: %+v", req.ID, &req) } } return web.RespondJson(ctx, w, nil, http.StatusNoContent) } -// Update updates the password for a specified user in the system. +// Read godoc +// @Summary Update user password by ID +// @Description Update updates the password for a specified user in the system. +// @Tags user +// @Accept json +// @Produce json +// @Security OAuth2Password +// @Param data body user.UserUpdatePasswordRequest true "Update fields" +// @Success 201 +// @Failure 400 {object} web.ErrorResponse +// @Failure 403 {object} web.ErrorResponse +// @Failure 404 {object} web.ErrorResponse +// @Failure 500 {object} web.ErrorResponse +// @Router /users/password [patch] func (u *User) UpdatePassword(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, ok := ctx.Value(web.KeyValues).(*web.Values) if !ok { @@ -169,9 +316,14 @@ func (u *User) UpdatePassword(ctx context.Context, w http.ResponseWriter, r *htt var req user.UserUpdatePasswordRequest if err := web.Decode(r, &req); err != nil { - return errors.Wrap(err, "") + err = errors.WithStack(err) + + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } + return err } - req.ID = params["id"] err := user.UpdatePassword(ctx, claims, u.MasterDB, req, v.Now) if err != nil { @@ -183,14 +335,32 @@ func (u *User) UpdatePassword(ctx context.Context, w http.ResponseWriter, r *htt case user.ErrForbidden: return web.NewRequestError(err, http.StatusForbidden) default: - return errors.Wrapf(err, "Id: %s User: %+v", params["id"], &req) + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } + + return errors.Wrapf(err, "Id: %s User: %+v", req.ID, &req) } } return web.RespondJson(ctx, w, nil, http.StatusNoContent) } -// Archive soft-deletes the specified user from the system. +// Read godoc +// @Summary Archive user by ID +// @Description Archive soft-deletes the specified user from the system. +// @Tags user +// @Accept json +// @Produce json +// @Security OAuth2Password +// @Param data body user.UserArchiveRequest true "Update fields" +// @Success 201 +// @Failure 400 {object} web.ErrorResponse +// @Failure 403 {object} web.ErrorResponse +// @Failure 404 {object} web.ErrorResponse +// @Failure 500 {object} web.ErrorResponse +// @Router /users/archive [patch] func (u *User) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, ok := ctx.Value(web.KeyValues).(*web.Values) if !ok { @@ -202,7 +372,18 @@ func (u *User) Archive(ctx context.Context, w http.ResponseWriter, r *http.Reque return errors.New("claims missing from context") } - err := user.Archive(ctx, claims, u.MasterDB, params["id"], v.Now) + var req user.UserArchiveRequest + if err := web.Decode(r, &req); err != nil { + err = errors.WithStack(err) + + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } + return err + } + + err := user.Archive(ctx, claims, u.MasterDB, req, v.Now) if err != nil { switch err { case user.ErrInvalidID: @@ -212,14 +393,32 @@ func (u *User) Archive(ctx context.Context, w http.ResponseWriter, r *http.Reque case user.ErrForbidden: return web.NewRequestError(err, http.StatusForbidden) default: - return errors.Wrapf(err, "Id: %s", params["id"]) + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } + + return errors.Wrapf(err, "Id: %s", req.ID) } } return web.RespondJson(ctx, w, nil, http.StatusNoContent) } -// Delete removes the specified user from the system. +// Delete godoc +// @Summary Delete user by ID +// @Description Delete removes the specified user from the system. +// @Tags user +// @Accept json +// @Produce json +// @Security OAuth2Password +// @Param id path string true "User ID" +// @Success 201 +// @Failure 400 {object} web.ErrorResponse +// @Failure 403 {object} web.ErrorResponse +// @Failure 404 {object} web.ErrorResponse +// @Failure 500 {object} web.ErrorResponse +// @Router /users/{id} [delete] func (u *User) Delete(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, ok := ctx.Value(auth.Key).(auth.Claims) if !ok { @@ -243,7 +442,20 @@ func (u *User) Delete(ctx context.Context, w http.ResponseWriter, r *http.Reques return web.RespondJson(ctx, w, nil, http.StatusNoContent) } -// SwitchAccount updates the claims. +// SwitchAccount godoc +// @Summary Switch account. +// @Description SwitchAccount updates the auth claims to a new account. +// @Tags user +// @Accept json +// @Produce json +// @Security OAuth2Password +// @Param account_id path int true "Account ID" +// @Success 201 +// @Failure 400 {object} web.ErrorResponse +// @Failure 403 {object} web.ErrorResponse +// @Failure 404 {object} web.ErrorResponse +// @Failure 500 {object} web.ErrorResponse +// @Router /users/switch-account/{account_id} [patch] func (u *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, ok := ctx.Value(web.KeyValues).(*web.Values) if !ok { @@ -255,12 +467,17 @@ func (u *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http return errors.New("claims missing from context") } - tkn, err := user.SwitchAccount(ctx, u.MasterDB, u.TokenGenerator, claims, params["accountId"], sessionTtl, v.Now) + tkn, err := user.SwitchAccount(ctx, u.MasterDB, u.TokenGenerator, claims, params["account_id"], sessionTtl, v.Now) if err != nil { switch err { case user.ErrAuthenticationFailure: return web.NewRequestError(err, http.StatusUnauthorized) default: + _, ok := err.(validator.ValidationErrors) + if ok { + return web.NewRequestError(err, http.StatusBadRequest) + } + return errors.Wrap(err, "switch account") } } @@ -275,6 +492,7 @@ func (u *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http // @Accept json // @Produce json // @Security BasicAuth +// @Param scope query string false "Scope" Enums(user, admin) // @Success 200 {object} user.Token // @Header 200 {string} Token "qwerty" // @Failure 400 {object} web.Error @@ -293,7 +511,10 @@ func (u *User) Token(ctx context.Context, w http.ResponseWriter, r *http.Request return web.NewRequestError(err, http.StatusUnauthorized) } - tkn, err := user.Authenticate(ctx, u.MasterDB, u.TokenGenerator, email, pass, sessionTtl, v.Now) + // Optional to include scope. + scope := r.URL.Query().Get("scope") + + tkn, err := user.Authenticate(ctx, u.MasterDB, u.TokenGenerator, email, pass, sessionTtl, v.Now, scope) if err != nil { switch err { case user.ErrAuthenticationFailure: diff --git a/example-project/cmd/web-api/main.go b/example-project/cmd/web-api/main.go index a7355b1..a3be5bb 100644 --- a/example-project/cmd/web-api/main.go +++ b/example-project/cmd/web-api/main.go @@ -55,9 +55,8 @@ var service = "WEB_API" // @securitydefinitions.oauth2.password OAuth2Password // @tokenUrl /v1/oauth/token -// @scope.read Grants read access -// @scope.write Grants write access -// @scope.admin Grants read and write access to administrative information +// @scope.user Grants basic privileges with role of user. +// @scope.admin Grants administrative privileges with role of admin. func main() { diff --git a/example-project/go.mod b/example-project/go.mod index b9226f0..380aed4 100644 --- a/example-project/go.mod +++ b/example-project/go.mod @@ -36,9 +36,9 @@ require ( github.com/swaggo/swag v1.5.1 github.com/tinylib/msgp v1.1.0 // indirect github.com/urfave/cli v1.20.0 + github.com/xwb1989/sqlparser v0.0.0-20180606152119-120387863bf2 golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4 golang.org/x/net v0.0.0-20190620200207-3b0461eec859 // indirect - golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0 // indirect golang.org/x/tools v0.0.0-20190624222133-a101b041ded4 // indirect google.golang.org/appengine v1.6.0 // indirect diff --git a/example-project/go.sum b/example-project/go.sum index a828603..306696c 100644 --- a/example-project/go.sum +++ b/example-project/go.sum @@ -1,4 +1,3 @@ -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= @@ -123,6 +122,8 @@ github.com/tinylib/msgp v1.1.0 h1:9fQd+ICuRIu/ue4vxJZu6/LzxN0HwMds2nq/0cFvxHU= github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= +github.com/xwb1989/sqlparser v0.0.0-20180606152119-120387863bf2 h1:zzrxE1FKn5ryBNl9eKOeqQ58Y/Qpo3Q9QNxKHX5uzzQ= +github.com/xwb1989/sqlparser v0.0.0-20180606152119-120387863bf2/go.mod h1:hzfGeIUDq/j97IG+FhNqkowIyEcD88LrW6fyU3K3WqY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4 h1:ydJNl0ENAG67pFbB+9tfhiL2pYqLhfoaZFw/cjLhY4A= @@ -131,16 +132,12 @@ golang.org/x/net v0.0.0-20180724234803-3673e40ba225 h1:kNX+jCowfMYzvlSvJu5pQWEmy golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -159,7 +156,6 @@ golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgw golang.org/x/tools v0.0.0-20190624222133-a101b041ded4 h1:1mMox4TgefDwqluYCv677yNXwlfTkija4owZve/jr78= golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.0 h1:Tfd7cKwKbFRsI8RMAD3oqqw7JPFRrvFlOsfbgVkjOOw= google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= gopkg.in/DataDog/dd-trace-go.v1 v1.15.0 h1:2LhklnAJsRSelbnBrrE5QuRleRDkmOh2JWxOtIX6yec= diff --git a/example-project/internal/account/account.go b/example-project/internal/account/account.go index db48010..a1bc83c 100644 --- a/example-project/internal/account/account.go +++ b/example-project/internal/account/account.go @@ -481,17 +481,18 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accoun return nil } -// Archive soft deleted the account from the database. -func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, accountID string, now time.Time) error { - span, ctx := tracer.StartSpanFromContext(ctx, "internal.account.Archive") - defer span.Finish() - - // Defines the struct to apply validation - req := struct { - ID string `validate:"required,uuid"` - }{ +// Archive soft deleted the account by ID from the database. +func ArchiveById(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, accountID string, now time.Time) error { + req := AccountArchiveRequest{ ID: accountID, } + return Archive(ctx, claims, dbConn, req, now) +} + +// Archive soft deleted the account from the database. +func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountArchiveRequest, now time.Time) error { + span, ctx := tracer.StartSpanFromContext(ctx, "internal.account.Archive") + defer span.Finish() // Validate the request. err := validator.New().Struct(req) diff --git a/example-project/internal/account/account_test.go b/example-project/internal/account/account_test.go index 8961fc3..103656b 100644 --- a/example-project/internal/account/account_test.go +++ b/example-project/internal/account/account_test.go @@ -792,7 +792,7 @@ func TestCrud(t *testing.T) { } // Archive (soft-delete) the account. - err = Archive(ctx, tt.claims(account, userId), test.MasterDB, account.ID, now) + err = ArchiveById(ctx, tt.claims(account, userId), test.MasterDB, account.ID, now) if err != nil && errors.Cause(err) != tt.updateErr { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", tt.updateErr) diff --git a/example-project/internal/account/models.go b/example-project/internal/account/models.go index 37bc491..f697877 100644 --- a/example-project/internal/account/models.go +++ b/example-project/internal/account/models.go @@ -1,8 +1,10 @@ package account import ( + "context" "database/sql" "database/sql/driver" + "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web" "time" "github.com/lib/pq" @@ -12,23 +14,79 @@ import ( // Account represents someone with access to our system. type Account struct { - ID string `json:"id" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` - Name string `json:"name" example:"Company Name"` - Address1 string `json:"address1" example:"221 Tatitlek Ave"` - Address2 string `json:"address2" example:"Box #1832"` - City string `json:"city" example:"Valdez"` - Region string `json:"region" example:"AK"` - Country string `json:"country" example:"USA"` - Zipcode string `json:"zipcode" example:"99686"` - Status AccountStatus `json:"status" swaggertype:"string" example:"active"` - Timezone string `json:"timezone" example:"America/Anchorage"` - SignupUserID *sql.NullString `json:"signup_user_id,omitempty" swaggertype:"string"` - BillingUserID *sql.NullString `json:"billing_user_id,omitempty" swaggertype:"string"` + ID string `json:"id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` + Name string `json:"name" validate:"required,unique" example:"Company Name"` + Address1 string `json:"address1" validate:"required" example:"221 Tatitlek Ave"` + Address2 string `json:"address2" validate:"omitempty" example:"Box #1832"` + City string `json:"city" validate:"required" example:"Valdez"` + Region string `json:"region" validate:"required" example:"AK"` + Country string `json:"country" validate:"required" example:"USA"` + Zipcode string `json:"zipcode" validate:"required" example:"99686"` + Status AccountStatus `json:"status" validate:"omitempty,oneof=active pending disabled" swaggertype:"string" enums:"active,pending,disabled" example:"active"` + Timezone string `json:"timezone" validate:"omitempty" example:"America/Anchorage"` + SignupUserID *sql.NullString `json:"signup_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + BillingUserID *sql.NullString `json:"billing_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` ArchivedAt *pq.NullTime `json:"archived_at,omitempty"` } +// AccountResponse represents someone with access to our system that is returned for display. +type AccountResponse struct { + ID string `json:"id" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` + Name string `json:"name" example:"Company Name"` + Address1 string `json:"address1" example:"221 Tatitlek Ave"` + Address2 string `json:"address2" example:"Box #1832"` + City string `json:"city" example:"Valdez"` + Region string `json:"region" example:"AK"` + Country string `json:"country" example:"USA"` + Zipcode string `json:"zipcode" example:"99686"` + Status web.EnumResponse `json:"status"` // Status is enum with values [active, pending, disabled]. + Timezone string `json:"timezone" example:"America/Anchorage"` + SignupUserID *string `json:"signup_user_id,omitempty" swaggertype:"string" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + BillingUserID *string `json:"billing_user_id,omitempty" swaggertype:"string" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + CreatedAt web.TimeResponse `json:"created_at"` // CreatedAt contains multiple format options for display. + UpdatedAt web.TimeResponse `json:"updated_at"` // UpdatedAt contains multiple format options for display. + ArchivedAt *web.TimeResponse `json:"archived_at,omitempty"` // ArchivedAt contains multiple format options for display. +} + +// Response transforms Account and AccountResponse that is used for display. +// Additional filtering by context values or translations could be applied. +func (m *Account) Response(ctx context.Context) *AccountResponse { + if m == nil { + return nil + } + + r := &AccountResponse{ + ID: m.ID, + Name: m.Name, + Address1: m.Address1, + Address2: m.Address2, + City: m.City, + Region: m.Region, + Country: m.Country, + Zipcode: m.Zipcode, + Timezone: m.Timezone, + Status: web.NewEnumResponse(ctx, m.Status, AccountStatus_Values), + CreatedAt: web.NewTimeResponse(ctx, m.CreatedAt), + UpdatedAt: web.NewTimeResponse(ctx, m.UpdatedAt), + } + + if m.SignupUserID != nil { + r.SignupUserID = &m.SignupUserID.String + } + if m.BillingUserID != nil { + r.BillingUserID = &m.BillingUserID.String + } + + if m.ArchivedAt != nil && !m.ArchivedAt.Time.IsZero() { + at := web.NewTimeResponse(ctx, m.ArchivedAt.Time) + r.ArchivedAt = &at + } + + return r +} + // AccountCreateRequest contains information needed to create a new Account. type AccountCreateRequest struct { Name string `json:"name" validate:"required,unique" example:"Company Name"` @@ -40,8 +98,8 @@ type AccountCreateRequest struct { Zipcode string `json:"zipcode" validate:"required" example:"99686"` Status *AccountStatus `json:"status,omitempty" validate:"omitempty,oneof=active pending disabled" swaggertype:"string" enums:"active,pending,disabled" example:"active"` Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"` - SignupUserID *string `json:"signup_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string"` - BillingUserID *string `json:"billing_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string"` + SignupUserID *string `json:"signup_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + BillingUserID *string `json:"billing_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` } // AccountUpdateRequest defines what information may be provided to modify an existing @@ -51,29 +109,35 @@ type AccountCreateRequest struct { // we do not want to use pointers to basic types but we make exceptions around // marshalling/unmarshalling. type AccountUpdateRequest struct { - ID string `json:"id" validate:"required,uuid"` - Name *string `json:"name,omitempty" validate:"omitempty,unique"` - Address1 *string `json:"address1,omitempty" validate:"omitempty"` - Address2 *string `json:"address2,omitempty" validate:"omitempty"` - City *string `json:"city,omitempty" validate:"omitempty"` - Region *string `json:"region,omitempty" validate:"omitempty"` - Country *string `json:"country,omitempty" validate:"omitempty"` - Zipcode *string `json:"zipcode,omitempty" validate:"omitempty"` - Status *AccountStatus `json:"status,omitempty" validate:"omitempty,oneof=active pending disabled" swaggertype:"string" enums:"active,pending,disabled"` - Timezone *string `json:"timezone,omitempty" validate:"omitempty"` - SignupUserID *string `json:"signup_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string"` - BillingUserID *string `json:"billing_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string"` + ID string `json:"id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` + Name *string `json:"name,omitempty" validate:"omitempty,unique" example:"Company Name"` + Address1 *string `json:"address1,omitempty" validate:"omitempty" example:"221 Tatitlek Ave"` + Address2 *string `json:"address2,omitempty" validate:"omitempty" example:"Box #1832"` + City *string `json:"city,omitempty" validate:"omitempty" example:"Valdez"` + Region *string `json:"region,omitempty" validate:"omitempty" example:"AK"` + Country *string `json:"country,omitempty" validate:"omitempty" example:"USA"` + Zipcode *string `json:"zipcode,omitempty" validate:"omitempty" example:"99686"` + Status *AccountStatus `json:"status,omitempty" validate:"omitempty,oneof=active pending disabled" swaggertype:"string" enums:"active,pending,disabled" example:"disabled"` + Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"` + SignupUserID *string `json:"signup_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + BillingUserID *string `json:"billing_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` +} + +// AccountArchiveRequest defines the information needed to archive an account. This will archive (soft-delete) the +// existing database entry. +type AccountArchiveRequest struct { + ID string `json:"id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` } // AccountFindRequest defines the possible options to search for accounts. By default // archived accounts will be excluded from response. type AccountFindRequest struct { - Where *string `json:"where"` - Args []interface{} `json:"args" swaggertype:"array,string"` - Order []string `json:"order"` - Limit *uint `json:"limit"` - Offset *uint `json:"offset"` - IncludedArchived bool `json:"included-archived"` + Where *string `json:"where" example:"name = ? and status = ?"` + Args []interface{} `json:"args" swaggertype:"array,string" example:"Company Name,active"` + Order []string `json:"order" example:"created_at desc"` + Limit *uint `json:"limit" example:"10"` + Offset *uint `json:"offset" example:"20"` + IncludedArchived bool `json:"included-archived" example:"false"` } // AccountStatus represents the status of an account. diff --git a/example-project/internal/platform/auth/claims.go b/example-project/internal/platform/auth/claims.go index d2198d2..94f155b 100644 --- a/example-project/internal/platform/auth/claims.go +++ b/example-project/internal/platform/auth/claims.go @@ -24,13 +24,15 @@ const Key ctxKey = 1 type Claims struct { AccountIds []string `json:"accounts"` Roles []string `json:"roles"` + Timezone string `json:"timezone"` + tz *time.Location jwt.StandardClaims } // NewClaims constructs a Claims value for the identified user. The Claims // expire within a specified duration of the provided time. Additional fields // of the Claims can be set after calling NewClaims is desired. -func NewClaims(userId, accountId string, accountIds []string, roles []string, now time.Time, expires time.Duration) Claims { +func NewClaims(userId, accountId string, accountIds []string, roles []string, userTimezone *time.Location, now time.Time, expires time.Duration) Claims { c := Claims{ AccountIds: accountIds, Roles: roles, @@ -42,6 +44,10 @@ func NewClaims(userId, accountId string, accountIds []string, roles []string, no }, } + if userTimezone != nil { + c.Timezone = userTimezone.String() + } + return c } @@ -71,3 +77,10 @@ func (c Claims) HasRole(roles ...string) bool { } return false } + +func (c Claims) TimeLocation() *time.Location { + if c.tz == nil && c.Timezone != "" { + c.tz, _ = time.LoadLocation(c.Timezone) + } + return c.tz +} diff --git a/example-project/internal/platform/session/session.go b/example-project/internal/platform/session/session.go new file mode 100644 index 0000000..22a25e6 --- /dev/null +++ b/example-project/internal/platform/session/session.go @@ -0,0 +1,16 @@ +package session + +import ( + "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth" +) + +// ctxKey represents the type of value for the context key. +type ctxKey int + +// Key is used to store/retrieve a Claims value from a context.Context. +const Key ctxKey = 1 + +// Session represents a user with authentication. +type Session struct { + Claims auth.Claims `json:"claims"` +} diff --git a/example-project/internal/platform/web/models.go b/example-project/internal/platform/web/models.go new file mode 100644 index 0000000..b9d227f --- /dev/null +++ b/example-project/internal/platform/web/models.go @@ -0,0 +1,99 @@ +package web + +import ( + "context" + "fmt" + "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth" + "github.com/dustin/go-humanize" + "strings" + "time" +) + +const DatetimeFormatLocal = "Mon Jan _2 3:04PM" +const DateFormatLocal = "Mon Jan _2" + +// TimeResponse is a response friendly format for displaying the value of a time. +type TimeResponse struct { + Value time.Time `json:"value" example:"2019-06-25T03:00:53.284-08:00"` + ValueUTC time.Time `json:"value_utc" example:"2019-06-25T11:00:53.284Z"` + Date string `json:"date" example:"2019-06-25"` + Time string `json:"time" example:"03:00:53"` + Kitchen string `json:"kitchen" example:"3:00AM"` + RFC1123 string `json:"rfc1123" example:"Tue, 25 Jun 2019 03:00:53 AKDT"` + Local string `json:"local" example:"Tue Jun 25 3:00AM"` + LocalDate string `json:"local_date" example:"Tue Jun 25"` + NowTime string `json:"now_time" example:"5 hours ago"` + NowRelTime string `json:"now_rel_time" example:"15 hours from now"` + Timezone string `json:"timezone" example:"America/Anchorage"` +} + +// NewTimeResponse parses the time to the timezone location set in context and +// returns the display friendly format as TimeResponse. +func NewTimeResponse(ctx context.Context, t time.Time) TimeResponse { + + // If the context has claims, check to see if timezone is set for the current user and + // then format the input time in that timezone if set. + claims, ok := ctx.Value(auth.Key).(auth.Claims) + if ok && claims.TimeLocation() != nil { + t = t.In(claims.TimeLocation()) + } + + tr := TimeResponse{ + Value: t, + ValueUTC: t.UTC(), + Date: t.Format("2006-01-02"), + Time: t.Format("15:04:05"), + Kitchen: t.Format(time.Kitchen), + RFC1123: t.Format(time.RFC1123), + Local: t.Format(DatetimeFormatLocal), + LocalDate: t.Format(DateFormatLocal), + NowTime: humanize.Time(t.UTC()), + NowRelTime: humanize.RelTime(time.Now().UTC(), t.UTC(), "ago", "from now"), + } + + if t.Location() != nil { + tr.Timezone = t.Location().String() + } + + return tr +} + +// EnumOption represents a single value of an enum option. +type EnumOption struct { + Value string `json:"value" example:"active_etc"` + Title string `json:"title" example:"Active Etc"` + Selected bool `json:"selected" example:"true"` +} + +// EnumResponse is a response friendly format for displaying an enum value that +// includes a list of all possible values. +type EnumResponse struct { + Value string `json:"value" example:"active_etc"` + Title string `json:"title" example:"Active Etc"` + Options []EnumOption `json:"options,omitempty"` +} + +// NewEnumResponse returns a display friendly format for a enum field. +func NewEnumResponse(ctx context.Context, value interface{}, options ...interface{}) EnumResponse { + er := EnumResponse{ + Value: fmt.Sprintf("%s", value), + Title: EnumValueTitle(fmt.Sprintf("%s", value)), + } + + for _, opt := range options { + optStr := fmt.Sprintf("%s", opt) + er.Options = append(er.Options, EnumOption{ + Value: optStr, + Title: EnumValueTitle(optStr), + Selected: (value == opt), + }) + } + + return er +} + +// EnumValueTitle formats a string value for display. +func EnumValueTitle(v string) string { + v = strings.Replace(v, "_", " ", -1) + return strings.Title(v) +} diff --git a/example-project/internal/platform/web/request.go b/example-project/internal/platform/web/request.go index 60160c4..633ca07 100644 --- a/example-project/internal/platform/web/request.go +++ b/example-project/internal/platform/web/request.go @@ -2,7 +2,6 @@ package web import ( "encoding/json" - "errors" "net/http" "reflect" "strings" @@ -10,6 +9,9 @@ import ( "github.com/go-playground/locales/en" ut "github.com/go-playground/universal-translator" "github.com/gorilla/schema" + "github.com/pkg/errors" + "github.com/xwb1989/sqlparser" + "github.com/xwb1989/sqlparser/dependency/querypb" "gopkg.in/go-playground/validator.v9" en_translations "gopkg.in/go-playground/validator.v9/translations/en" ) @@ -97,3 +99,43 @@ func Decode(r *http.Request, val interface{}) error { return nil } + +// ExtractWhereArgs extracts the sql args from where. This allows requests to accept sql queries for filters and +// then replaces the raw values with placeholders. The resulting query will then be executed with bind vars. +func ExtractWhereArgs(where string) (string, []interface{}, error) { + // Create a full select sql query. + query := "select `t` from test where " + where + + // Parse the query. + stmt, err := sqlparser.Parse(query) + if err != nil { + return "", nil, errors.WithMessagef(err, "Failed to parse query - %s", where) + } + + // Normalize changes the query statement to use bind values, and updates the bind vars to those values. The + // supplied prefix is used to generate the bind var names. + bindVars := make(map[string]*querypb.BindVariable) + sqlparser.Normalize(stmt, bindVars, "redacted") + + // Loop through all the bind vars and append to the response args list. + var vals []interface{} + for _, bv := range bindVars { + if bv.Values != nil { + var l []interface{} + for _, v := range bv.Values { + l = append(l, string(v.Value)) + } + vals = append(vals, l) + } else { + vals = append(vals, string(bv.Value)) + } + } + + // Update the original query to include the redacted values. + query = sqlparser.String(stmt) + + // Parse out the updated where. + where = strings.Split(query, " where ")[1] + + return where, vals, nil +} diff --git a/example-project/internal/platform/web/request_test.go b/example-project/internal/platform/web/request_test.go new file mode 100644 index 0000000..94659db --- /dev/null +++ b/example-project/internal/platform/web/request_test.go @@ -0,0 +1,64 @@ +package web + +import ( + "github.com/google/go-cmp/cmp" + "testing" +) + +func TestExtractWhereArgs(t *testing.T) { + + var queryTests = []struct { + where string + redacted string + args []interface{} + }{ + { + "name = 'xxxx' or name = :test", + "name = :redacted1 or name = :test", + []interface{}{"xxxx"}, + }, + { + "name = 'xxxx' or name is null", + "name = :redacted1 or name is null", + []interface{}{"xxxx"}, + }, + { + "name = 'xxxx' or name in ('yyyy', 'zzzz')", + "name = :redacted1 or name in ::redacted2", + []interface{}{"xxxx", []interface{}{"yyyy", "zzzz"}}, + }, + { + "id = 3232 or id in (2323, 3239, 483484)", + "id = :redacted1 or id in ::redacted2", + []interface{}{"3232", []interface{}{"2323", "3239", "483484"}}, + }, + } + + t.Log("Given the need to ensure values are correctly extracted from a where query string.") + { + for i, tt := range queryTests { + t.Logf("\tTest: %d\tWhen running test: #%d", i, i) + { + res, args, err := ExtractWhereArgs(tt.where) + if err != nil { + t.Log("\t\tGot :", err) + t.Fatalf("\t\tExtract failed.") + } + + if res != tt.redacted { + t.Logf("\t\tGot : %+v", res) + t.Logf("\t\tWant: %+v", tt.redacted) + t.Fatalf("\t\tResulting where does not match expected.") + } + + if diff := cmp.Diff(tt.args, args); diff != "" { + t.Logf("\t\tGot : %+v", args) + t.Logf("\t\tWant: %+v", tt.args) + t.Fatalf("\t\tResulting args does not match expected. Diff:\n%s", diff) + } + + t.Logf("\t\tOk.") + } + } + } +} diff --git a/example-project/internal/project/models.go b/example-project/internal/project/models.go index 1aa853b..dc63549 100644 --- a/example-project/internal/project/models.go +++ b/example-project/internal/project/models.go @@ -1,7 +1,9 @@ package project import ( + "context" "database/sql/driver" + "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web" "github.com/lib/pq" "github.com/pkg/errors" "gopkg.in/go-playground/validator.v9" @@ -12,18 +14,53 @@ import ( type Project struct { ID string `json:"id" validate:"required,uuid" example:"985f1746-1d9f-459f-a2d9-fc53ece5ae86"` AccountID string `json:"account_id" validate:"required,uuid" truss:"api-create"` - Name string `json:"name" validate:"required"` - Status ProjectStatus `json:"status" validate:"omitempty,oneof=active disabled" enums:"active,disabled" swaggertype:"string"` + Name string `json:"name" validate:"required" example:"Rocket Launch"` + Status ProjectStatus `json:"status" validate:"omitempty,oneof=active disabled" enums:"active,disabled" swaggertype:"string" example:"active"` CreatedAt time.Time `json:"created_at" truss:"api-read"` UpdatedAt time.Time `json:"updated_at" truss:"api-read"` ArchivedAt *pq.NullTime `json:"archived_at,omitempty" truss:"api-hide"` } +// ProjectResponse represents a workflow that is returned for display. +type ProjectResponse struct { + ID string `json:"id" validate:"required,uuid" example:"985f1746-1d9f-459f-a2d9-fc53ece5ae86"` + AccountID string `json:"account_id" validate:"required,uuid" truss:"api-create" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` + Name string `json:"name" validate:"required" example:"Rocket Launch"` + Status web.EnumResponse `json:"status"` // Status is enum with values [active, disabled]. + CreatedAt web.TimeResponse `json:"created_at"` // CreatedAt contains multiple format options for display. + UpdatedAt web.TimeResponse `json:"updated_at"` // UpdatedAt contains multiple format options for display. + ArchivedAt *web.TimeResponse `json:"archived_at,omitempty"` // ArchivedAt contains multiple format options for display. +} + +// Response transforms Project and ProjectResponse that is used for display. +// Additional filtering by context values or translations could be applied. +func (m *Project) Response(ctx context.Context) *ProjectResponse { + if m == nil { + return nil + } + + r := &ProjectResponse{ + ID: m.ID, + AccountID: m.AccountID, + Name: m.Name, + Status: web.NewEnumResponse(ctx, m.Status, ProjectStatus_Values), + CreatedAt: web.NewTimeResponse(ctx, m.CreatedAt), + UpdatedAt: web.NewTimeResponse(ctx, m.UpdatedAt), + } + + if m.ArchivedAt != nil && !m.ArchivedAt.Time.IsZero() { + at := web.NewTimeResponse(ctx, m.ArchivedAt.Time) + r.ArchivedAt = &at + } + + return r +} + // ProjectCreateRequest contains information needed to create a new Project. type ProjectCreateRequest struct { - AccountID string `json:"account_id" validate:"required,uuid"` - Name string `json:"name" validate:"required"` - Status *ProjectStatus `json:"status,omitempty" validate:"omitempty,oneof=active disabled" enums:"active,disabled" swaggertype:"string"` + AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` + Name string `json:"name" validate:"required" example:"Rocket Launch"` + Status *ProjectStatus `json:"status,omitempty" validate:"omitempty,oneof=active disabled" enums:"active,disabled" swaggertype:"string" example:"active"` } // ProjectUpdateRequest defines what information may be provided to modify an existing @@ -31,20 +68,26 @@ type ProjectCreateRequest struct { // changed. It uses pointer fields so we can differentiate between a field that // was not provided and a field that was provided as explicitly blank. type ProjectUpdateRequest struct { - ID string `json:"id" validate:"required,uuid"` - Name *string `json:"name,omitempty" validate:"omitempty"` - Status *ProjectStatus `json:"status,omitempty" validate:"omitempty,oneof=active disabled" enums:"active,disabled" swaggertype:"string"` + ID string `json:"id" validate:"required,uuid" example:"985f1746-1d9f-459f-a2d9-fc53ece5ae86"` + Name *string `json:"name,omitempty" validate:"omitempty" example:"Rocket Launch to Moon"` + Status *ProjectStatus `json:"status,omitempty" validate:"omitempty,oneof=active disabled" enums:"active,disabled" swaggertype:"string" example:"disabled"` +} + +// ProjectArchiveRequest defines the information needed to archive a project. This will archive (soft-delete) the +// existing database entry. +type ProjectArchiveRequest struct { + ID string `json:"id" validate:"required,uuid" example:"985f1746-1d9f-459f-a2d9-fc53ece5ae86"` } // ProjectFindRequest defines the possible options to search for projects. By default // archived project will be excluded from response. type ProjectFindRequest struct { - Where *string `json:"where"` - Args []interface{} `json:"args" swaggertype:"array,string"` - Order []string `json:"order"` - Limit *uint `json:"limit"` - Offset *uint `json:"offset"` - IncludedArchived bool `json:"included-archived"` + Where *string `json:"where" example:"name = ? and status = ?"` + Args []interface{} `json:"args" swaggertype:"array,string" example:"Moon Launch,active"` + Order []string `json:"order" example:"created_at desc"` + Limit *uint `json:"limit" example:"10"` + Offset *uint `json:"offset" example:"20"` + IncludedArchived bool `json:"included-archived" example:"false"` } // ProjectStatus represents the status of project. diff --git a/example-project/internal/project/project.go b/example-project/internal/project/project.go index 809f5d8..c918b21 100644 --- a/example-project/internal/project/project.go +++ b/example-project/internal/project/project.go @@ -358,14 +358,19 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Projec return nil } +// Archive soft deleted the project by ID from the database. +func ArchiveById(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string, now time.Time) error { + req := ProjectArchiveRequest{ + ID: id, + } + return Archive(ctx, claims, dbConn, req, now) +} + // Archive soft deleted the project from the database. -func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string, now time.Time) error { +func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req ProjectArchiveRequest, now time.Time) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.project.Archive") defer span.Finish() - // Defines the struct to apply validation - req := struct { - ID string `validate:"required,uuid"` - }{} + // Validate the request. err := validator.New().Struct(req) if err != nil { diff --git a/example-project/internal/user/auth.go b/example-project/internal/user/auth.go index b6d3d2f..2be639c 100644 --- a/example-project/internal/user/auth.go +++ b/example-project/internal/user/auth.go @@ -3,7 +3,9 @@ package user import ( "context" "crypto/rsa" + "database/sql" "github.com/dgrijalva/jwt-go" + "strings" "time" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth" @@ -26,7 +28,7 @@ type TokenGenerator interface { // Authenticate finds a user by their email and verifies their password. On success // it returns a Token that can be used to authenticate access to the application in // the future. -func Authenticate(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, email, password string, expires time.Duration, now time.Time) (Token, error) { +func Authenticate(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, email, password string, expires time.Duration, now time.Time, scopes ...string) (Token, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Authenticate") defer span.Finish() @@ -58,13 +60,13 @@ func Authenticate(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, e } // The user is successfully authenticated with the supplied email and password. - return generateToken(ctx, dbConn, tknGen, auth.Claims{}, u.ID, "", expires, now) + return generateToken(ctx, dbConn, tknGen, auth.Claims{}, u.ID, "", expires, now, scopes...) } // Authenticate finds a user by their email and verifies their password. On success // it returns a Token that can be used to authenticate access to the application in // the future. -func SwitchAccount(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, claims auth.Claims, accountID string, expires time.Duration, now time.Time) (Token, error) { +func SwitchAccount(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, claims auth.Claims, accountID string, expires time.Duration, now time.Time, scopes ...string) (Token, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.SwitchAccount") defer span.Finish() @@ -86,12 +88,12 @@ func SwitchAccount(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, // Generate a token for the user ID in supplied in claims as the Subject. Pass // in the supplied claims as well to enforce ACLs when finding the current // list of accounts for the user. - return generateToken(ctx, dbConn, tknGen, claims, req.UserID, req.AccountID, expires, now) + return generateToken(ctx, dbConn, tknGen, claims, req.UserID, req.AccountID, expires, now, scopes...) } // generateToken generates claims for the supplied user ID and account ID and then // returns the token for the generated claims used for authentication. -func generateToken(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, claims auth.Claims, userID, accountID string, expires time.Duration, now time.Time) (Token, error) { +func generateToken(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, claims auth.Claims, userID, accountID string, expires time.Duration, now time.Time, scopes ...string) (Token, error) { type userAccount struct { AccountID string @@ -100,13 +102,16 @@ func generateToken(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, UserArchived pq.NullTime AccountStatus string AccountArchived pq.NullTime + AccountTimezone sql.NullString + UserTimezone sql.NullString } // Build select statement for users_accounts table to find all the user accounts for the user f := func() ([]userAccount, error) { - query := sqlbuilder.NewSelectBuilder().Select("ua.account_id, ua.roles, ua.status as userStatus, ua.archived_at userArchived, a.status as accountStatus, a.archived_at as accountArchived"). + query := sqlbuilder.NewSelectBuilder().Select("ua.account_id, ua.roles, ua.status as userStatus, ua.archived_at userArchived, a.status as accountStatus, a.archived_at, a.timezone, u.timezone as accountArchived"). From(userAccountTableName+" ua"). - Join(accountTableName+" a", "a.id = ua.account_id") + Join(accountTableName+" a", "a.id = ua.account_id"). + Join(userTableName+" u", "u.id = ua.user_id") query.Where(query.And( query.Equal("ua.user_id", userID), )) @@ -125,7 +130,7 @@ func generateToken(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, var resp []userAccount for rows.Next() { var ua userAccount - err = rows.Scan(&ua.AccountID, &ua.Roles, &ua.UserStatus, &ua.UserArchived, &ua.AccountStatus, &ua.AccountArchived) + err = rows.Scan(&ua.AccountID, &ua.Roles, &ua.UserStatus, &ua.UserArchived, &ua.AccountStatus, &ua.AccountArchived, &ua.AccountTimezone, &ua.UserTimezone) if err != nil { return nil, errors.WithStack(err) } @@ -212,11 +217,64 @@ func generateToken(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, accountIds = append(accountIds, a.AccountID) } + // Allow the scope to be defined for the claims. This enables testing via the API when a user has the role of admin + // and would like to limit their role to user. + var roles []string + if len(scopes) > 0 && scopes[0] != "" { + // Parse scopes, handle when one value has a list of scopes + // separated by a space. + var scopeList []string + for _, vs := range scopes { + for _, v := range strings.Split(vs, " ") { + v = strings.TrimSpace(v) + if v == "" { + continue + } + scopeList = append(scopeList, v) + } + } + + for _, s := range scopeList { + var scopeValid bool + for _, r := range account.Roles { + if r == s || (s == auth.RoleUser && r == auth.RoleAdmin) { + scopeValid = true + break + } + } + + if scopeValid { + roles = append(roles, s) + } else { + err := errors.Errorf("invalid scope '%s'", s) + return Token{}, err + } + } + } else { + roles = account.Roles + } + + if len(roles) == 0 { + err := errors.New("no roles defined for user") + return Token{}, err + } + + // Set the timezone if one is specifically set on the user. + var tz *time.Location + if account.UserTimezone.Valid && account.UserTimezone.String != "" { + tz, _ = time.LoadLocation(account.UserTimezone.String) + } + + // If user timezone failed to parse or none is set, check the timezone set on the account. + if tz == nil && account.AccountTimezone.Valid && account.AccountTimezone.String != "" { + tz, _ = time.LoadLocation(account.AccountTimezone.String) + } + // JWT claims requires both an audience and a subject. For this application: // Subject: The ID of the user authenticated. // Audience: The ID of the account the user is accessing. A list of account IDs // will also be included to support the user switching between them. - claims = auth.NewClaims(userID, accountID, accountIds, account.Roles, now, expires) + claims = auth.NewClaims(userID, accountID, accountIds, roles, tz, now, expires) // Generate a token for the user with the defined claims. tknStr, err := tknGen.GenerateToken(claims) diff --git a/example-project/internal/user/auth_test.go b/example-project/internal/user/auth_test.go index b917ab6..b5dd9dc 100644 --- a/example-project/internal/user/auth_test.go +++ b/example-project/internal/user/auth_test.go @@ -1,6 +1,7 @@ package user import ( + "encoding/json" "testing" "time" @@ -109,12 +110,13 @@ func TestAuthenticate(t *testing.T) { if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tParse claims from token failed.", tests.Failed) - } else if diff := cmp.Diff(claims1, tkn1.claims); diff != "" { + } + + // Hack for Unhandled Exception in go-cmp@v0.3.0/cmp/options.go:229 + resClaims, _ := json.Marshal(claims1) + expectClaims, _ := json.Marshal(tkn1.claims) + if diff := cmp.Diff(string(resClaims), string(expectClaims)); diff != "" { t.Fatalf("\t%s\tExpected parsed claims to match from token. Diff:\n%s", tests.Failed, diff) - } else if diff := cmp.Diff(claims1.Roles, []string{account1Role}); diff != "" { - t.Fatalf("\t%s\tExpected parsed claims roles to match user account. Diff:\n%s", tests.Failed, diff) - } else if diff := cmp.Diff(claims1.AccountIds, []string{account1Id, account2Id}); diff != "" { - t.Fatalf("\t%s\tExpected parsed claims account IDs to match the single user account. Diff:\n%s", tests.Failed, diff) } t.Logf("\t%s\tAuthenticate parse claims from token ok.", tests.Success) @@ -131,12 +133,13 @@ func TestAuthenticate(t *testing.T) { if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tParse claims from token failed.", tests.Failed) - } else if diff := cmp.Diff(claims2, tkn2.claims); diff != "" { + } + + // Hack for Unhandled Exception in go-cmp@v0.3.0/cmp/options.go:229 + resClaims, _ = json.Marshal(claims2) + expectClaims, _ = json.Marshal(tkn2.claims) + if diff := cmp.Diff(string(resClaims), string(expectClaims)); diff != "" { t.Fatalf("\t%s\tExpected parsed claims to match from token. Diff:\n%s", tests.Failed, diff) - } else if diff := cmp.Diff(claims2.Roles, []string{account2Role}); diff != "" { - t.Fatalf("\t%s\tExpected parsed claims roles to match user account. Diff:\n%s", tests.Failed, diff) - } else if diff := cmp.Diff(claims2.AccountIds, []string{account1Id, account2Id}); diff != "" { - t.Fatalf("\t%s\tExpected parsed claims account IDs to match the single user account. Diff:\n%s", tests.Failed, diff) } t.Logf("\t%s\tSwitchAccount parse claims from token ok.", tests.Success) } diff --git a/example-project/internal/user/models.go b/example-project/internal/user/models.go index ee6a232..623eddc 100644 --- a/example-project/internal/user/models.go +++ b/example-project/internal/user/models.go @@ -1,8 +1,10 @@ package user import ( + "context" "database/sql" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth" + "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web" "time" "github.com/lib/pq" @@ -10,25 +12,57 @@ import ( // User represents someone with access to our system. type User struct { - ID string `json:"id" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` - Name string `json:"name" validate:"required" example:"Gabi May"` - Email string `json:"email" example:"gabi@geeksinthewoods.com"` - - PasswordSalt string `json:"-"` - PasswordHash []byte `json:"-"` + ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + Name string `json:"name" validate:"required" example:"Gabi May"` + Email string `json:"email" validate:"required,email,unique" example:"gabi@geeksinthewoods.com"` + PasswordSalt string `json:"-" validate:"required"` + PasswordHash []byte `json:"-" validate:"required"` PasswordReset *sql.NullString `json:"-"` + Timezone string `json:"timezone" validate:"omitempty" example:"America/Anchorage"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ArchivedAt *pq.NullTime `json:"archived_at,omitempty"` +} - Timezone string `json:"timezone" example:"America/Anchorage"` +// UserResponse represents someone with access to our system that is returned for display. +type UserResponse struct { + ID string `json:"id" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + Name string `json:"name" example:"Gabi May"` + Email string `json:"email" example:"gabi@geeksinthewoods.com"` + Timezone string `json:"timezone" example:"America/Anchorage"` + CreatedAt web.TimeResponse `json:"created_at"` // CreatedAt contains multiple format options for display. + UpdatedAt web.TimeResponse `json:"updated_at"` // UpdatedAt contains multiple format options for display. + ArchivedAt *web.TimeResponse `json:"archived_at,omitempty"` // ArchivedAt contains multiple format options for display. +} - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - ArchivedAt *pq.NullTime `json:"archived_at,omitempty"` +// Response transforms User and UserResponse that is used for display. +// Additional filtering by context values or translations could be applied. +func (m *User) Response(ctx context.Context) *UserResponse { + if m == nil { + return nil + } + + r := &UserResponse{ + ID: m.ID, + Name: m.Name, + Email: m.Email, + Timezone: m.Timezone, + CreatedAt: web.NewTimeResponse(ctx, m.CreatedAt), + UpdatedAt: web.NewTimeResponse(ctx, m.UpdatedAt), + } + + if m.ArchivedAt != nil && !m.ArchivedAt.Time.IsZero() { + at := web.NewTimeResponse(ctx, m.ArchivedAt.Time) + r.ArchivedAt = &at + } + + return r } // UserCreateRequest contains information needed to create a new User. type UserCreateRequest struct { - Name string `json:"name" validate:"required" example:"Gabi May"` - Email string `json:"email" validate:"required,email,unique" example:"gabi@geeksinthewoods.com"` + Name string `json:"name" validate:"required" example:"Gabi May"` + Email string `json:"email" validate:"required,email,unique" example:"gabi@geeksinthewoods.com"` Password string `json:"password" validate:"required" example:"SecretString"` PasswordConfirm string `json:"password_confirm" validate:"eqfield=Password" example:"SecretString"` Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"` @@ -41,28 +75,34 @@ type UserCreateRequest struct { // we do not want to use pointers to basic types but we make exceptions around // marshalling/unmarshalling. type UserUpdateRequest struct { - ID string `json:"id" validate:"required,uuid"` - Name *string `json:"name,omitempty" validate:"omitempty"` - Email *string `json:"email,omitempty" validate:"omitempty,email,unique"` - Timezone *string `json:"timezone,omitempty" validate:"omitempty"` + ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + Name *string `json:"name,omitempty" validate:"omitempty" example:"Gabi May Not"` + Email *string `json:"email,omitempty" validate:"omitempty,email,unique" example:"gabi.may@geeksinthewoods.com"` + Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"` } // UserUpdatePasswordRequest defines what information is required to update a user password. type UserUpdatePasswordRequest struct { - ID string `json:"id" validate:"required,uuid"` - Password string `json:"password" validate:"required"` - PasswordConfirm string `json:"password_confirm" validate:"omitempty,eqfield=Password"` + ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + Password string `json:"password" validate:"required" example:"NeverTellSecret"` + PasswordConfirm string `json:"password_confirm" validate:"omitempty,eqfield=Password" example:"NeverTellSecret"` +} + +// UserArchiveRequest defines the information needed to archive an user. This will archive (soft-delete) the +// existing database entry. +type UserArchiveRequest struct { + ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` } // UserFindRequest defines the possible options to search for users. By default // archived users will be excluded from response. type UserFindRequest struct { - Where *string `json:"where"` - Args []interface{} `json:"args" swaggertype:"array,string"` - Order []string `json:"order"` - Limit *uint `json:"limit"` - Offset *uint `json:"offset"` - IncludedArchived bool `json:"included-archived"` + Where *string `json:"where" example:"name = ? and email = ?"` + Args []interface{} `json:"args" swaggertype:"array,string" example:"Company Name,gabi.may@geeksinthewoods.com"` + Order []string `json:"order" example:"created_at desc"` + Limit *uint `json:"limit" example:"10"` + Offset *uint `json:"offset" example:"20"` + IncludedArchived bool `json:"included-archived" example:"false"` } // Token is the payload we deliver to users when they authenticate. diff --git a/example-project/internal/user/user.go b/example-project/internal/user/user.go index b2cd190..2ec3907 100644 --- a/example-project/internal/user/user.go +++ b/example-project/internal/user/user.go @@ -512,18 +512,19 @@ func UpdatePassword(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, re return nil } +// Archive soft deleted the user by ID from the database. +func ArchiveById(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string, now time.Time) error { + req := UserArchiveRequest{ + ID: id, + } + return Archive(ctx, claims, dbConn, req, now) +} + // Archive soft deleted the user from the database. -func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID string, now time.Time) error { +func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserArchiveRequest, now time.Time) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Archive") defer span.Finish() - // Defines the struct to apply validation - req := struct { - ID string `validate:"required,uuid"` - }{ - ID: userID, - } - // Validate the request. err := validator.New().Struct(req) if err != nil { diff --git a/example-project/internal/user/user_test.go b/example-project/internal/user/user_test.go index e0e65c3..59f7ed2 100644 --- a/example-project/internal/user/user_test.go +++ b/example-project/internal/user/user_test.go @@ -827,7 +827,7 @@ func TestCrud(t *testing.T) { } // Archive (soft-delete) the user. - err = Archive(ctx, tt.claims(user, accountId), test.MasterDB, user.ID, now) + err = ArchiveById(ctx, tt.claims(user, accountId), test.MasterDB, user.ID, now) if err != nil && errors.Cause(err) != tt.updateErr { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", tt.updateErr) diff --git a/example-project/internal/user_account/models.go b/example-project/internal/user_account/models.go index 2edcff6..6dfd419 100644 --- a/example-project/internal/user_account/models.go +++ b/example-project/internal/user_account/models.go @@ -1,7 +1,9 @@ package user_account import ( + "context" "database/sql/driver" + "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web" "time" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth" @@ -17,16 +19,53 @@ import ( // application. The status will allow users to be managed on by account with users // being global to the application. type UserAccount struct { - ID string `json:"id" example:"72938896-a998-4258-a17b-6418dcdb80e3"` - UserID string `json:"user_id" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` - AccountID string `json:"account_id" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` - Roles UserAccountRoles `json:"roles" swaggertype:"array,string" enums:"admin,user" example:"admin"` - Status UserAccountStatus `json:"status" swaggertype:"string" enums:"active,invited,disabled" example:"active"` + ID string `json:"id" validate:"required,uuid" example:"72938896-a998-4258-a17b-6418dcdb80e3"` + UserID string `json:"user_id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` + Roles UserAccountRoles `json:"roles" validate:"required,dive,oneof=admin user" enums:"admin,user" swaggertype:"array,string" example:"admin"` + Status UserAccountStatus `json:"status" validate:"omitempty,oneof=active invited disabled" enums:"active,invited,disabled" swaggertype:"string" example:"active"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` ArchivedAt *pq.NullTime `json:"archived_at,omitempty"` } +// UserAccountResponse defines the one to many relationship of an user to an account that is returned for display. +type UserAccountResponse struct { + ID string `json:"id" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + UserID string `json:"user_id" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + AccountID string `json:"account_id" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` + Roles UserAccountRoles `json:"roles" validate:"required,dive,oneof=admin user" enums:"admin,user" swaggertype:"array,string" example:"admin"` + Status web.EnumResponse `json:"status"` // Status is enum with values [active, invited, disabled]. + CreatedAt web.TimeResponse `json:"created_at"` // CreatedAt contains multiple format options for display. + UpdatedAt web.TimeResponse `json:"updated_at"` // UpdatedAt contains multiple format options for display. + ArchivedAt *web.TimeResponse `json:"archived_at,omitempty"` // ArchivedAt contains multiple format options for display. +} + +// Response transforms UserAccount and UserAccountResponse that is used for display. +// Additional filtering by context values or translations could be applied. +func (m *UserAccount) Response(ctx context.Context) *UserAccountResponse { + if m == nil { + return nil + } + + r := &UserAccountResponse{ + ID: m.ID, + UserID: m.UserID, + AccountID: m.AccountID, + Roles: m.Roles, + Status: web.NewEnumResponse(ctx, m.Status, UserAccountRole_Values), + CreatedAt: web.NewTimeResponse(ctx, m.CreatedAt), + UpdatedAt: web.NewTimeResponse(ctx, m.UpdatedAt), + } + + if m.ArchivedAt != nil && !m.ArchivedAt.Time.IsZero() { + at := web.NewTimeResponse(ctx, m.ArchivedAt.Time) + r.ArchivedAt = &at + } + + return r +} + // CreateUserAccountRequest defines the information is needed to associate a user to an // account. Users are global to the application and each users access can be managed // on an account level. If a current entry exists in the database but is archived, @@ -41,8 +80,8 @@ type CreateUserAccountRequest struct { // UpdateUserAccountRequest defines the information needed to update the roles or the // status for an existing user account. type UpdateUserAccountRequest struct { - UserID string `json:"user_id" validate:"required,uuid"` - AccountID string `json:"account_id" validate:"required,uuid"` + UserID string `json:"user_id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` Roles *UserAccountRoles `json:"roles,omitempty" validate:"required,dive,oneof=admin user" enums:"admin,user" swaggertype:"array,string" example:"user"` Status *UserAccountStatus `json:"status,omitempty" validate:"omitempty,oneof=active invited disabled" enums:"active,invited,disabled" swaggertype:"string" example:"disabled"` unArchive bool `json:"-"` // Internal use only. @@ -51,26 +90,26 @@ type UpdateUserAccountRequest struct { // ArchiveUserAccountRequest defines the information needed to remove an existing account // for a user. This will archive (soft-delete) the existing database entry. type ArchiveUserAccountRequest struct { - UserID string `json:"user_id" validate:"required,uuid"` - AccountID string `json:"account_id" validate:"required,uuid"` + UserID string `json:"user_id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` } // DeleteUserAccountRequest defines the information needed to delete an existing account // for a user. This will hard delete the existing database entry. type DeleteUserAccountRequest struct { - UserID string `json:"user_id" validate:"required,uuid"` - AccountID string `json:"account_id" validate:"required,uuid"` + UserID string `json:"user_id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` } // UserAccountFindRequest defines the possible options to search for users accounts. // By default archived user accounts will be excluded from response. type UserAccountFindRequest struct { - Where *string `json:"where"` - Args []interface{} `json:"args" swaggertype:"array,string"` - Order []string `json:"order"` - Limit *uint `json:"limit"` - Offset *uint `json:"offset"` - IncludedArchived bool `json:"included-archived"` + Where *string `json:"where" example:"user_id = ? and account_id = ?"` + Args []interface{} `json:"args" swaggertype:"array,string" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2,c4653bf9-5978-48b7-89c5-95704aebb7e2"` + Order []string `json:"order" example:"created_at desc"` + Limit *uint `json:"limit" example:"10"` + Offset *uint `json:"offset" example:"20"` + IncludedArchived bool `json:"included-archived" example:"false"` } // UserAccountStatus represents the status of a user for an account.