diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..e5d51a2 --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,1056 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api/auth/login": { + "post": { + "description": "Authenticate with email and password to receive access and refresh tokens", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Login", + "parameters": [ + { + "description": "Login credentials", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.Auth" + } + } + ], + "responses": { + "200": { + "description": "access_token, refresh_token, expires_in", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad request", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized (invalid credentials)", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/auth/logout": { + "post": { + "description": "Invalidate the given refresh token to log out the current session", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Logout", + "parameters": [ + { + "description": "Refresh token to invalidate", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.LogoutRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad request", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/auth/logout/all": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Invalidate all refresh tokens for the authenticated user", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Logout all sessions", + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/auth/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Return profile information for the authenticated user", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Get current user", + "responses": { + "200": { + "description": "id, email, created_at, updated_at", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/auth/me/password": { + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update the password of the currently authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Change password", + "parameters": [ + { + "description": "Old and new password", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.ChangePasswordRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad request (e.g. incorrect old password)", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/auth/refresh": { + "post": { + "description": "Exchange a valid refresh token for a new access token and refresh token pair", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Refresh access token", + "parameters": [ + { + "description": "Refresh token payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "access_token, refresh_token, expires_in", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad request", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized (invalid or expired refresh token)", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/auth/register": { + "post": { + "description": "Create a new user account with email and password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Register a new user", + "parameters": [ + { + "description": "Register credentials", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.Auth" + } + } + ], + "responses": { + "201": { + "description": "Created" + }, + "400": { + "description": "Bad request (e.g. email already exists, password too short)", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/form": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new form with title, description, and questions for the authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "forms" + ], + "summary": "Create a form", + "parameters": [ + { + "description": "Form creation payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.CreateFormRequest" + } + } + ], + "responses": { + "201": { + "description": "Created form", + "schema": { + "$ref": "#/definitions/handler.FormResponse" + } + }, + "400": { + "description": "Bad request (e.g. title is required)", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/form/{id}": { + "get": { + "description": "Retrieve a form and its questions by form ID (publicly accessible)", + "produces": [ + "application/json" + ], + "tags": [ + "forms" + ], + "summary": "Get a form", + "parameters": [ + { + "type": "string", + "description": "Form ID (UUID)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Form details", + "schema": { + "$ref": "#/definitions/handler.FormResponse" + } + }, + "400": { + "description": "Invalid form ID", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Form not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Replace a form's title, description, and questions. Only the form owner can update it", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "forms" + ], + "summary": "Update a form", + "parameters": [ + { + "type": "string", + "description": "Form ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Form update payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.UpdateFormRequest" + } + } + ], + "responses": { + "200": { + "description": "Updated form", + "schema": { + "$ref": "#/definitions/handler.FormResponse" + } + }, + "400": { + "description": "Bad request (e.g. title is required)", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "Forbidden (not the form owner)", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Form not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a form by ID. Only the form owner can delete it. Deletion is blocked if the form already has responses", + "produces": [ + "application/json" + ], + "tags": [ + "forms" + ], + "summary": "Delete a form", + "parameters": [ + { + "type": "string", + "description": "Form ID (UUID)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Invalid form ID", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "Forbidden (not the form owner)", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Form not found", + "schema": { + "type": "string" + } + }, + "409": { + "description": "Conflict (form already has responses)", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/form/{id}/response": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Submit answers to a form's questions. Authentication is optional (anonymous submissions allowed). Required questions must be answered. Choice answers must match valid options", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "forms" + ], + "summary": "Submit a form response", + "parameters": [ + { + "type": "string", + "description": "Form ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Response answers payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.SubmitResponseRequest" + } + } + ], + "responses": { + "201": { + "description": "Submitted response", + "schema": { + "$ref": "#/definitions/handler.SubmitResponseResponse" + } + }, + "400": { + "description": "Bad request (e.g. invalid answer, missing required question)", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Form not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/form/{id}/responses": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Retrieve all submitted responses for a form. Only the form owner can access this", + "produces": [ + "application/json" + ], + "tags": [ + "forms" + ], + "summary": "Get form responses", + "parameters": [ + { + "type": "string", + "description": "Form ID (UUID)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "List of form responses with answers", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.SubmitResponseResponse" + } + } + }, + "400": { + "description": "Invalid form ID", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "Forbidden (not the form owner)", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Form not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/forms": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Retrieve all forms belonging to the authenticated user, with optional search, status filter, and sort options", + "produces": [ + "application/json" + ], + "tags": [ + "forms" + ], + "summary": "List forms", + "parameters": [ + { + "type": "string", + "description": "Filter by title (case-insensitive)", + "name": "search", + "in": "query" + }, + { + "type": "string", + "description": "Filter by response status: has_responses | no_responses", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Sort field: created_at (default) | updated_at", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "Sort direction: newest (default) | oldest", + "name": "sort_dir", + "in": "query" + } + ], + "responses": { + "200": { + "description": "List of forms", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.FormResponse" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/health": { + "get": { + "description": "Returns 200 OK if the server is running", + "produces": [ + "text/plain" + ], + "tags": [ + "health" + ], + "summary": "Health check", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + } + } + } + } + }, + "definitions": { + "handler.AnswerInput": { + "type": "object", + "properties": { + "answer": { + "type": "string" + }, + "question_id": { + "type": "string" + } + } + }, + "handler.AnswerResponse": { + "type": "object", + "properties": { + "answer": { + "type": "string" + }, + "question_id": { + "type": "string" + } + } + }, + "handler.Auth": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "handler.ChangePasswordRequest": { + "type": "object", + "properties": { + "new_password": { + "type": "string" + }, + "old_password": { + "type": "string" + } + } + }, + "handler.CreateFormRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.QuestionInput" + } + }, + "title": { + "type": "string" + } + } + }, + "handler.FormResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.QuestionResponse" + } + }, + "response_count": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "handler.LogoutRequest": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "handler.QuestionInput": { + "type": "object", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.QuestionOptionInput" + } + }, + "position": { + "type": "integer" + }, + "required": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "handler.QuestionOptionInput": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "position": { + "type": "integer" + } + } + }, + "handler.QuestionOptionResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "position": { + "type": "integer" + } + } + }, + "handler.QuestionResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.QuestionOptionResponse" + } + }, + "position": { + "type": "integer" + }, + "required": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "handler.RefreshRequest": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "handler.SubmitResponseRequest": { + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.AnswerInput" + } + } + } + }, + "handler.SubmitResponseResponse": { + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.AnswerResponse" + } + }, + "form_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "submitted_at": { + "type": "string" + } + } + }, + "handler.UpdateFormRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.QuestionInput" + } + }, + "title": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Enter your bearer token in the format: Bearer {token}", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "localhost:8080", + BasePath: "/", + Schemes: []string{}, + Title: "Ristek Task API", + Description: "REST API for Ristek Task Backend", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..fd2cac8 --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,1032 @@ +{ + "swagger": "2.0", + "info": { + "description": "REST API for Ristek Task Backend", + "title": "Ristek Task API", + "contact": {}, + "version": "1.0" + }, + "host": "localhost:8080", + "basePath": "/", + "paths": { + "/api/auth/login": { + "post": { + "description": "Authenticate with email and password to receive access and refresh tokens", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Login", + "parameters": [ + { + "description": "Login credentials", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.Auth" + } + } + ], + "responses": { + "200": { + "description": "access_token, refresh_token, expires_in", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad request", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized (invalid credentials)", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/auth/logout": { + "post": { + "description": "Invalidate the given refresh token to log out the current session", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Logout", + "parameters": [ + { + "description": "Refresh token to invalidate", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.LogoutRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad request", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/auth/logout/all": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Invalidate all refresh tokens for the authenticated user", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Logout all sessions", + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/auth/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Return profile information for the authenticated user", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Get current user", + "responses": { + "200": { + "description": "id, email, created_at, updated_at", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/auth/me/password": { + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update the password of the currently authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Change password", + "parameters": [ + { + "description": "Old and new password", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.ChangePasswordRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad request (e.g. incorrect old password)", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/auth/refresh": { + "post": { + "description": "Exchange a valid refresh token for a new access token and refresh token pair", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Refresh access token", + "parameters": [ + { + "description": "Refresh token payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "access_token, refresh_token, expires_in", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad request", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized (invalid or expired refresh token)", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/auth/register": { + "post": { + "description": "Create a new user account with email and password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Register a new user", + "parameters": [ + { + "description": "Register credentials", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.Auth" + } + } + ], + "responses": { + "201": { + "description": "Created" + }, + "400": { + "description": "Bad request (e.g. email already exists, password too short)", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/form": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new form with title, description, and questions for the authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "forms" + ], + "summary": "Create a form", + "parameters": [ + { + "description": "Form creation payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.CreateFormRequest" + } + } + ], + "responses": { + "201": { + "description": "Created form", + "schema": { + "$ref": "#/definitions/handler.FormResponse" + } + }, + "400": { + "description": "Bad request (e.g. title is required)", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/form/{id}": { + "get": { + "description": "Retrieve a form and its questions by form ID (publicly accessible)", + "produces": [ + "application/json" + ], + "tags": [ + "forms" + ], + "summary": "Get a form", + "parameters": [ + { + "type": "string", + "description": "Form ID (UUID)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Form details", + "schema": { + "$ref": "#/definitions/handler.FormResponse" + } + }, + "400": { + "description": "Invalid form ID", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Form not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Replace a form's title, description, and questions. Only the form owner can update it", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "forms" + ], + "summary": "Update a form", + "parameters": [ + { + "type": "string", + "description": "Form ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Form update payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.UpdateFormRequest" + } + } + ], + "responses": { + "200": { + "description": "Updated form", + "schema": { + "$ref": "#/definitions/handler.FormResponse" + } + }, + "400": { + "description": "Bad request (e.g. title is required)", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "Forbidden (not the form owner)", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Form not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a form by ID. Only the form owner can delete it. Deletion is blocked if the form already has responses", + "produces": [ + "application/json" + ], + "tags": [ + "forms" + ], + "summary": "Delete a form", + "parameters": [ + { + "type": "string", + "description": "Form ID (UUID)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Invalid form ID", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "Forbidden (not the form owner)", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Form not found", + "schema": { + "type": "string" + } + }, + "409": { + "description": "Conflict (form already has responses)", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/form/{id}/response": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Submit answers to a form's questions. Authentication is optional (anonymous submissions allowed). Required questions must be answered. Choice answers must match valid options", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "forms" + ], + "summary": "Submit a form response", + "parameters": [ + { + "type": "string", + "description": "Form ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Response answers payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.SubmitResponseRequest" + } + } + ], + "responses": { + "201": { + "description": "Submitted response", + "schema": { + "$ref": "#/definitions/handler.SubmitResponseResponse" + } + }, + "400": { + "description": "Bad request (e.g. invalid answer, missing required question)", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Form not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/form/{id}/responses": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Retrieve all submitted responses for a form. Only the form owner can access this", + "produces": [ + "application/json" + ], + "tags": [ + "forms" + ], + "summary": "Get form responses", + "parameters": [ + { + "type": "string", + "description": "Form ID (UUID)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "List of form responses with answers", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.SubmitResponseResponse" + } + } + }, + "400": { + "description": "Invalid form ID", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "Forbidden (not the form owner)", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Form not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/forms": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Retrieve all forms belonging to the authenticated user, with optional search, status filter, and sort options", + "produces": [ + "application/json" + ], + "tags": [ + "forms" + ], + "summary": "List forms", + "parameters": [ + { + "type": "string", + "description": "Filter by title (case-insensitive)", + "name": "search", + "in": "query" + }, + { + "type": "string", + "description": "Filter by response status: has_responses | no_responses", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Sort field: created_at (default) | updated_at", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "Sort direction: newest (default) | oldest", + "name": "sort_dir", + "in": "query" + } + ], + "responses": { + "200": { + "description": "List of forms", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.FormResponse" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/health": { + "get": { + "description": "Returns 200 OK if the server is running", + "produces": [ + "text/plain" + ], + "tags": [ + "health" + ], + "summary": "Health check", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + } + } + } + } + }, + "definitions": { + "handler.AnswerInput": { + "type": "object", + "properties": { + "answer": { + "type": "string" + }, + "question_id": { + "type": "string" + } + } + }, + "handler.AnswerResponse": { + "type": "object", + "properties": { + "answer": { + "type": "string" + }, + "question_id": { + "type": "string" + } + } + }, + "handler.Auth": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "handler.ChangePasswordRequest": { + "type": "object", + "properties": { + "new_password": { + "type": "string" + }, + "old_password": { + "type": "string" + } + } + }, + "handler.CreateFormRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.QuestionInput" + } + }, + "title": { + "type": "string" + } + } + }, + "handler.FormResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.QuestionResponse" + } + }, + "response_count": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "handler.LogoutRequest": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "handler.QuestionInput": { + "type": "object", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.QuestionOptionInput" + } + }, + "position": { + "type": "integer" + }, + "required": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "handler.QuestionOptionInput": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "position": { + "type": "integer" + } + } + }, + "handler.QuestionOptionResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "position": { + "type": "integer" + } + } + }, + "handler.QuestionResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.QuestionOptionResponse" + } + }, + "position": { + "type": "integer" + }, + "required": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "handler.RefreshRequest": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "handler.SubmitResponseRequest": { + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.AnswerInput" + } + } + } + }, + "handler.SubmitResponseResponse": { + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.AnswerResponse" + } + }, + "form_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "submitted_at": { + "type": "string" + } + } + }, + "handler.UpdateFormRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.QuestionInput" + } + }, + "title": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Enter your bearer token in the format: Bearer {token}", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..1186c69 --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,675 @@ +basePath: / +definitions: + handler.AnswerInput: + properties: + answer: + type: string + question_id: + type: string + type: object + handler.AnswerResponse: + properties: + answer: + type: string + question_id: + type: string + type: object + handler.Auth: + properties: + email: + type: string + password: + type: string + type: object + handler.ChangePasswordRequest: + properties: + new_password: + type: string + old_password: + type: string + type: object + handler.CreateFormRequest: + properties: + description: + type: string + questions: + items: + $ref: '#/definitions/handler.QuestionInput' + type: array + title: + type: string + type: object + handler.FormResponse: + properties: + created_at: + type: string + description: + type: string + id: + type: string + questions: + items: + $ref: '#/definitions/handler.QuestionResponse' + type: array + response_count: + type: integer + title: + type: string + updated_at: + type: string + user_id: + type: string + type: object + handler.LogoutRequest: + properties: + refresh_token: + type: string + type: object + handler.QuestionInput: + properties: + options: + items: + $ref: '#/definitions/handler.QuestionOptionInput' + type: array + position: + type: integer + required: + type: boolean + title: + type: string + type: + type: string + type: object + handler.QuestionOptionInput: + properties: + label: + type: string + position: + type: integer + type: object + handler.QuestionOptionResponse: + properties: + id: + type: integer + label: + type: string + position: + type: integer + type: object + handler.QuestionResponse: + properties: + id: + type: string + options: + items: + $ref: '#/definitions/handler.QuestionOptionResponse' + type: array + position: + type: integer + required: + type: boolean + title: + type: string + type: + type: string + type: object + handler.RefreshRequest: + properties: + refresh_token: + type: string + type: object + handler.SubmitResponseRequest: + properties: + answers: + items: + $ref: '#/definitions/handler.AnswerInput' + type: array + type: object + handler.SubmitResponseResponse: + properties: + answers: + items: + $ref: '#/definitions/handler.AnswerResponse' + type: array + form_id: + type: string + id: + type: string + submitted_at: + type: string + type: object + handler.UpdateFormRequest: + properties: + description: + type: string + questions: + items: + $ref: '#/definitions/handler.QuestionInput' + type: array + title: + type: string + type: object +host: localhost:8080 +info: + contact: {} + description: REST API for Ristek Task Backend + title: Ristek Task API + version: "1.0" +paths: + /api/auth/login: + post: + consumes: + - application/json + description: Authenticate with email and password to receive access and refresh + tokens + parameters: + - description: Login credentials + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.Auth' + produces: + - application/json + responses: + "200": + description: access_token, refresh_token, expires_in + schema: + additionalProperties: true + type: object + "400": + description: Bad request + schema: + type: string + "401": + description: Unauthorized (invalid credentials) + schema: + type: string + "500": + description: Internal server error + schema: + type: string + summary: Login + tags: + - auth + /api/auth/logout: + post: + consumes: + - application/json + description: Invalidate the given refresh token to log out the current session + parameters: + - description: Refresh token to invalidate + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.LogoutRequest' + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad request + schema: + type: string + "500": + description: Internal server error + schema: + type: string + summary: Logout + tags: + - auth + /api/auth/logout/all: + delete: + description: Invalidate all refresh tokens for the authenticated user + produces: + - application/json + responses: + "204": + description: No Content + "401": + description: Unauthorized + schema: + type: string + "500": + description: Internal server error + schema: + type: string + security: + - BearerAuth: [] + summary: Logout all sessions + tags: + - auth + /api/auth/me: + get: + description: Return profile information for the authenticated user + produces: + - application/json + responses: + "200": + description: id, email, created_at, updated_at + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + type: string + "500": + description: Internal server error + schema: + type: string + security: + - BearerAuth: [] + summary: Get current user + tags: + - auth + /api/auth/me/password: + patch: + consumes: + - application/json + description: Update the password of the currently authenticated user + parameters: + - description: Old and new password + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.ChangePasswordRequest' + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad request (e.g. incorrect old password) + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "500": + description: Internal server error + schema: + type: string + security: + - BearerAuth: [] + summary: Change password + tags: + - auth + /api/auth/refresh: + post: + consumes: + - application/json + description: Exchange a valid refresh token for a new access token and refresh + token pair + parameters: + - description: Refresh token payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.RefreshRequest' + produces: + - application/json + responses: + "200": + description: access_token, refresh_token, expires_in + schema: + additionalProperties: true + type: object + "400": + description: Bad request + schema: + type: string + "401": + description: Unauthorized (invalid or expired refresh token) + schema: + type: string + "500": + description: Internal server error + schema: + type: string + summary: Refresh access token + tags: + - auth + /api/auth/register: + post: + consumes: + - application/json + description: Create a new user account with email and password + parameters: + - description: Register credentials + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.Auth' + produces: + - application/json + responses: + "201": + description: Created + "400": + description: Bad request (e.g. email already exists, password too short) + schema: + type: string + "500": + description: Internal server error + schema: + type: string + summary: Register a new user + tags: + - auth + /api/form: + post: + consumes: + - application/json + description: Create a new form with title, description, and questions for the + authenticated user + parameters: + - description: Form creation payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.CreateFormRequest' + produces: + - application/json + responses: + "201": + description: Created form + schema: + $ref: '#/definitions/handler.FormResponse' + "400": + description: Bad request (e.g. title is required) + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "500": + description: Internal server error + schema: + type: string + security: + - BearerAuth: [] + summary: Create a form + tags: + - forms + /api/form/{id}: + delete: + description: Delete a form by ID. Only the form owner can delete it. Deletion + is blocked if the form already has responses + parameters: + - description: Form ID (UUID) + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Invalid form ID + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: Forbidden (not the form owner) + schema: + type: string + "404": + description: Form not found + schema: + type: string + "409": + description: Conflict (form already has responses) + schema: + type: string + "500": + description: Internal server error + schema: + type: string + security: + - BearerAuth: [] + summary: Delete a form + tags: + - forms + get: + description: Retrieve a form and its questions by form ID (publicly accessible) + parameters: + - description: Form ID (UUID) + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: Form details + schema: + $ref: '#/definitions/handler.FormResponse' + "400": + description: Invalid form ID + schema: + type: string + "404": + description: Form not found + schema: + type: string + "500": + description: Internal server error + schema: + type: string + summary: Get a form + tags: + - forms + put: + consumes: + - application/json + description: Replace a form's title, description, and questions. Only the form + owner can update it + parameters: + - description: Form ID (UUID) + in: path + name: id + required: true + type: string + - description: Form update payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.UpdateFormRequest' + produces: + - application/json + responses: + "200": + description: Updated form + schema: + $ref: '#/definitions/handler.FormResponse' + "400": + description: Bad request (e.g. title is required) + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: Forbidden (not the form owner) + schema: + type: string + "404": + description: Form not found + schema: + type: string + "500": + description: Internal server error + schema: + type: string + security: + - BearerAuth: [] + summary: Update a form + tags: + - forms + /api/form/{id}/response: + post: + consumes: + - application/json + description: Submit answers to a form's questions. Authentication is optional + (anonymous submissions allowed). Required questions must be answered. Choice + answers must match valid options + parameters: + - description: Form ID (UUID) + in: path + name: id + required: true + type: string + - description: Response answers payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.SubmitResponseRequest' + produces: + - application/json + responses: + "201": + description: Submitted response + schema: + $ref: '#/definitions/handler.SubmitResponseResponse' + "400": + description: Bad request (e.g. invalid answer, missing required question) + schema: + type: string + "404": + description: Form not found + schema: + type: string + "500": + description: Internal server error + schema: + type: string + security: + - BearerAuth: [] + summary: Submit a form response + tags: + - forms + /api/form/{id}/responses: + get: + description: Retrieve all submitted responses for a form. Only the form owner + can access this + parameters: + - description: Form ID (UUID) + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: List of form responses with answers + schema: + items: + $ref: '#/definitions/handler.SubmitResponseResponse' + type: array + "400": + description: Invalid form ID + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: Forbidden (not the form owner) + schema: + type: string + "404": + description: Form not found + schema: + type: string + "500": + description: Internal server error + schema: + type: string + security: + - BearerAuth: [] + summary: Get form responses + tags: + - forms + /api/forms: + get: + description: Retrieve all forms belonging to the authenticated user, with optional + search, status filter, and sort options + parameters: + - description: Filter by title (case-insensitive) + in: query + name: search + type: string + - description: 'Filter by response status: has_responses | no_responses' + in: query + name: status + type: string + - description: 'Sort field: created_at (default) | updated_at' + in: query + name: sort_by + type: string + - description: 'Sort direction: newest (default) | oldest' + in: query + name: sort_dir + type: string + produces: + - application/json + responses: + "200": + description: List of forms + schema: + items: + $ref: '#/definitions/handler.FormResponse' + type: array + "401": + description: Unauthorized + schema: + type: string + "500": + description: Internal server error + schema: + type: string + security: + - BearerAuth: [] + summary: List forms + tags: + - forms + /health: + get: + description: Returns 200 OK if the server is running + produces: + - text/plain + responses: + "200": + description: OK + schema: + type: string + summary: Health check + tags: + - health +securityDefinitions: + BearerAuth: + description: 'Enter your bearer token in the format: Bearer {token}' + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/go.mod b/go.mod index 4d21bb0..61c0775 100644 --- a/go.mod +++ b/go.mod @@ -11,20 +11,37 @@ require ( ) require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.20.0 // indirect + github.com/go-openapi/spec v0.20.6 // indirect + github.com/go-openapi/swag v0.19.15 // indirect github.com/goccy/go-json v0.10.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/dsig v1.0.0 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc/v3 v3.0.2 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect github.com/segmentio/asm v1.2.1 // indirect + github.com/swaggo/files v1.0.1 // indirect + github.com/swaggo/files/v2 v2.0.0 // indirect + github.com/swaggo/http-swagger/v2 v2.0.2 // indirect + github.com/swaggo/swag v1.16.6 // indirect github.com/valyala/fastjson v1.6.7 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.41.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 359c52f..e07ca47 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,29 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= +github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= +github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -17,6 +38,12 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= @@ -31,27 +58,83 @@ github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0 github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= +github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= +github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg= +github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/handler/auth.go b/internal/handler/auth.go index b746abd..515d718 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -30,6 +30,18 @@ func isDuplicateError(err error) bool { return false } +// RegisterPost registers a new user account +// +// @Summary Register a new user +// @Description Create a new user account with email and password +// @Tags auth +// @Accept json +// @Produce json +// @Param body body Auth true "Register credentials" +// @Success 201 +// @Failure 400 {string} string "Bad request (e.g. email already exists, password too short)" +// @Failure 500 {string} string "Internal server error" +// @Router /api/auth/register [post] func (h *Handler) RegisterPost(w http.ResponseWriter, r *http.Request) { var register Auth if err := json.NewDecoder(r.Body).Decode(®ister); err != nil { @@ -83,6 +95,19 @@ type RefreshRequest struct { RefreshToken string `json:"refresh_token"` } +// RefreshPost refreshes the access token using a refresh token +// +// @Summary Refresh access token +// @Description Exchange a valid refresh token for a new access token and refresh token pair +// @Tags auth +// @Accept json +// @Produce json +// @Param body body RefreshRequest true "Refresh token payload" +// @Success 200 {object} map[string]interface{} "access_token, refresh_token, expires_in" +// @Failure 400 {string} string "Bad request" +// @Failure 401 {string} string "Unauthorized (invalid or expired refresh token)" +// @Failure 500 {string} string "Internal server error" +// @Router /api/auth/refresh [post] func (h *Handler) RefreshPost(w http.ResponseWriter, r *http.Request) { var req RefreshRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -164,6 +189,18 @@ type LogoutRequest struct { RefreshToken string `json:"refresh_token"` } +// LogoutPost logs out the current session by invalidating the refresh token +// +// @Summary Logout +// @Description Invalidate the given refresh token to log out the current session +// @Tags auth +// @Accept json +// @Produce json +// @Param body body LogoutRequest true "Refresh token to invalidate" +// @Success 204 +// @Failure 400 {string} string "Bad request" +// @Failure 500 {string} string "Internal server error" +// @Router /api/auth/logout [post] func (h *Handler) LogoutPost(w http.ResponseWriter, r *http.Request) { var req LogoutRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -189,6 +226,17 @@ func (h *Handler) LogoutPost(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// LogoutAllDelete logs out all sessions for the authenticated user +// +// @Summary Logout all sessions +// @Description Invalidate all refresh tokens for the authenticated user +// @Tags auth +// @Produce json +// @Security BearerAuth +// @Success 204 +// @Failure 401 {string} string "Unauthorized" +// @Failure 500 {string} string "Internal server error" +// @Router /api/auth/logout/all [delete] func (h *Handler) LogoutAllDelete(w http.ResponseWriter, r *http.Request) { userIDStr, ok := r.Context().Value(middleware.UserIDKey).(string) if !ok || userIDStr == "" { @@ -214,6 +262,17 @@ func (h *Handler) LogoutAllDelete(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// MeGet returns the profile of the currently authenticated user +// +// @Summary Get current user +// @Description Return profile information for the authenticated user +// @Tags auth +// @Produce json +// @Security BearerAuth +// @Success 200 {object} map[string]interface{} "id, email, created_at, updated_at" +// @Failure 401 {string} string "Unauthorized" +// @Failure 500 {string} string "Internal server error" +// @Router /api/auth/me [get] func (h *Handler) MeGet(w http.ResponseWriter, r *http.Request) { userIDStr, ok := r.Context().Value(middleware.UserIDKey).(string) if !ok || userIDStr == "" { @@ -256,6 +315,20 @@ type ChangePasswordRequest struct { NewPassword string `json:"new_password"` } +// MePasswordPatch changes the password of the authenticated user +// +// @Summary Change password +// @Description Update the password of the currently authenticated user +// @Tags auth +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body ChangePasswordRequest true "Old and new password" +// @Success 204 +// @Failure 400 {string} string "Bad request (e.g. incorrect old password)" +// @Failure 401 {string} string "Unauthorized" +// @Failure 500 {string} string "Internal server error" +// @Router /api/auth/me/password [patch] func (h *Handler) MePasswordPatch(w http.ResponseWriter, r *http.Request) { userIDStr, ok := r.Context().Value(middleware.UserIDKey).(string) if !ok || userIDStr == "" { @@ -320,6 +393,19 @@ func (h *Handler) MePasswordPatch(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// LoginPost authenticates a user and returns tokens +// +// @Summary Login +// @Description Authenticate with email and password to receive access and refresh tokens +// @Tags auth +// @Accept json +// @Produce json +// @Param body body Auth true "Login credentials" +// @Success 200 {object} map[string]interface{} "access_token, refresh_token, expires_in" +// @Failure 400 {string} string "Bad request" +// @Failure 401 {string} string "Unauthorized (invalid credentials)" +// @Failure 500 {string} string "Internal server error" +// @Router /api/auth/login [post] func (h *Handler) LoginPost(w http.ResponseWriter, r *http.Request) { var login Auth if err := json.NewDecoder(r.Body).Decode(&login); err != nil { diff --git a/internal/handler/form.go b/internal/handler/form.go index bc3d613..160ffab 100644 --- a/internal/handler/form.go +++ b/internal/handler/form.go @@ -137,6 +137,21 @@ func isChoiceType(t repository.QuestionType) bool { return false } +// FormsGet returns all forms owned by the authenticated user +// +// @Summary List forms +// @Description Retrieve all forms belonging to the authenticated user, with optional search, status filter, and sort options +// @Tags forms +// @Produce json +// @Security BearerAuth +// @Param search query string false "Filter by title (case-insensitive)" +// @Param status query string false "Filter by response status: has_responses | no_responses" +// @Param sort_by query string false "Sort field: created_at (default) | updated_at" +// @Param sort_dir query string false "Sort direction: newest (default) | oldest" +// @Success 200 {array} FormResponse "List of forms" +// @Failure 401 {string} string "Unauthorized" +// @Failure 500 {string} string "Internal server error" +// @Router /api/forms [get] func (h *Handler) FormsGet(w http.ResponseWriter, r *http.Request) { userID, ok := h.currentUserID(r) if !ok { @@ -223,6 +238,20 @@ func (h *Handler) FormsGet(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(items) } +// FormsPost creates a new form +// +// @Summary Create a form +// @Description Create a new form with title, description, and questions for the authenticated user +// @Tags forms +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body CreateFormRequest true "Form creation payload" +// @Success 201 {object} FormResponse "Created form" +// @Failure 400 {string} string "Bad request (e.g. title is required)" +// @Failure 401 {string} string "Unauthorized" +// @Failure 500 {string} string "Internal server error" +// @Router /api/form [post] func (h *Handler) FormsPost(w http.ResponseWriter, r *http.Request) { userID, ok := h.currentUserID(r) if !ok { @@ -270,6 +299,18 @@ func (h *Handler) FormsPost(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(buildFormResponse(form, questions, options)) } +// FormGet retrieves a single form by ID +// +// @Summary Get a form +// @Description Retrieve a form and its questions by form ID (publicly accessible) +// @Tags forms +// @Produce json +// @Param id path string true "Form ID (UUID)" +// @Success 200 {object} FormResponse "Form details" +// @Failure 400 {string} string "Invalid form ID" +// @Failure 404 {string} string "Form not found" +// @Failure 500 {string} string "Internal server error" +// @Router /api/form/{id} [get] func (h *Handler) FormGet(w http.ResponseWriter, r *http.Request) { formID, err := uuid.Parse(r.PathValue("id")) if err != nil { @@ -310,6 +351,23 @@ func (h *Handler) FormGet(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(buildFormResponse(form, questions, options)) } +// FormPut updates an existing form +// +// @Summary Update a form +// @Description Replace a form's title, description, and questions. Only the form owner can update it +// @Tags forms +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Form ID (UUID)" +// @Param body body UpdateFormRequest true "Form update payload" +// @Success 200 {object} FormResponse "Updated form" +// @Failure 400 {string} string "Bad request (e.g. title is required)" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "Forbidden (not the form owner)" +// @Failure 404 {string} string "Form not found" +// @Failure 500 {string} string "Internal server error" +// @Router /api/form/{id} [put] func (h *Handler) FormPut(w http.ResponseWriter, r *http.Request) { userID, ok := h.currentUserID(r) if !ok { @@ -389,6 +447,22 @@ func (h *Handler) FormPut(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(buildFormResponse(updated, questions, options)) } +// FormDelete deletes a form +// +// @Summary Delete a form +// @Description Delete a form by ID. Only the form owner can delete it. Deletion is blocked if the form already has responses +// @Tags forms +// @Produce json +// @Security BearerAuth +// @Param id path string true "Form ID (UUID)" +// @Success 204 +// @Failure 400 {string} string "Invalid form ID" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "Forbidden (not the form owner)" +// @Failure 404 {string} string "Form not found" +// @Failure 409 {string} string "Conflict (form already has responses)" +// @Failure 500 {string} string "Internal server error" +// @Router /api/form/{id} [delete] func (h *Handler) FormDelete(w http.ResponseWriter, r *http.Request) { userID, ok := h.currentUserID(r) if !ok { @@ -508,6 +582,21 @@ type SubmitResponseResponse struct { Answers []AnswerResponse `json:"answers"` } +// FormResponsesPost submits a response to a form +// +// @Summary Submit a form response +// @Description Submit answers to a form's questions. Authentication is optional (anonymous submissions allowed). Required questions must be answered. Choice answers must match valid options +// @Tags forms +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Form ID (UUID)" +// @Param body body SubmitResponseRequest true "Response answers payload" +// @Success 201 {object} SubmitResponseResponse "Submitted response" +// @Failure 400 {string} string "Bad request (e.g. invalid answer, missing required question)" +// @Failure 404 {string} string "Form not found" +// @Failure 500 {string} string "Internal server error" +// @Router /api/form/{id}/response [post] func (h *Handler) FormResponsesPost(w http.ResponseWriter, r *http.Request) { formID, err := uuid.Parse(r.PathValue("id")) if err != nil { @@ -668,6 +757,21 @@ func (h *Handler) FormResponsesPost(w http.ResponseWriter, r *http.Request) { }) } +// FormResponsesGet retrieves all responses for a form +// +// @Summary Get form responses +// @Description Retrieve all submitted responses for a form. Only the form owner can access this +// @Tags forms +// @Produce json +// @Security BearerAuth +// @Param id path string true "Form ID (UUID)" +// @Success 200 {array} SubmitResponseResponse "List of form responses with answers" +// @Failure 400 {string} string "Invalid form ID" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "Forbidden (not the form owner)" +// @Failure 404 {string} string "Form not found" +// @Failure 500 {string} string "Internal server error" +// @Router /api/form/{id}/responses [get] func (h *Handler) FormResponsesGet(w http.ResponseWriter, r *http.Request) { userID, ok := h.currentUserID(r) if !ok { diff --git a/internal/handler/handler.go b/internal/handler/handler.go index db692c9..9432f55 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -31,6 +31,14 @@ func internalServerError(w http.ResponseWriter, err error) { _, _ = w.Write([]byte(err.Error())) } +// HealthGet returns the health status of the server +// +// @Summary Health check +// @Description Returns 200 OK if the server is running +// @Tags health +// @Produce plain +// @Success 200 {string} string "OK" +// @Router /health [get] func (h *Handler) HealthGet(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("OK")) diff --git a/internal/server/server.go b/internal/server/server.go index 1a4264b..d0488fa 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,10 +3,13 @@ package server import ( "fmt" "net/http" + _ "ristek-task-be/docs" "ristek-task-be/internal/db/sqlc/repository" "ristek-task-be/internal/handler" "ristek-task-be/internal/jwt" "ristek-task-be/internal/middleware" + + httpSwagger "github.com/swaggo/http-swagger/v2" ) type Server struct { @@ -30,6 +33,7 @@ func router(repository *repository.Queries, jwt *jwt.JWT) *http.ServeMux { h := handler.New(repository, jwt) r.HandleFunc("GET /health", h.HealthGet) + r.Handle("/swagger/", httpSwagger.Handler(httpSwagger.URL("/swagger/doc.json"))) authRoute := http.NewServeMux() r.Handle("/api/auth/", http.StripPrefix("/api/auth", authRoute)) diff --git a/main.go b/main.go index f7d6a83..1ecbbe4 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,16 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) +// @title Ristek Task API +// @version 1.0 +// @description REST API for Ristek Task Backend +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization +// @description Enter your bearer token in the format: Bearer {token} + +// @host localhost:8080 +// @BasePath / func main() { log.SetOutput(os.Stdout) log.SetFlags(log.LstdFlags | log.Lshortfile)