commit 6b2fd4dbc593491d90e95d0013b31624c5f7f930 Author: Danil Solovyov Date: Thu Apr 20 07:03:21 2023 +0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f9facd --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bolt.db +test.env +.idea \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc8b44d --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Сервис формы обратной связи шаблонизатора + +## Описание: +Сервис для получения обратной связи. Отправляет все запросы в телеграм канал. + +## Технологии +Golang, BoltDB, Telegram, http + +## Переменные окружения + +``` +TELEGRAM_TOKEN - токен телеграм клиента +TELEGRAM_CHANNEL_ID - телеграм канал для отправки сообщений +TEMPLATE_PATH - путь файла шаблона сообщения +HTTP_RATE_LIMIT - время ограничения между запросами +HTTP_ADDRESS - адрес работы http сервера +``` + +## Пример переменных окружений +``` +TELEGRAM_TOKEN=ffjlaksj234da:f3mzf4 +TELEGRAM_CHANNEL_ID=-3214219 +TEMPLATE_PATH=assets/template_msg.txt +HTTP_RATE_LIMIT=30s +HTTP_ADDRESS=:80 +``` +## Ссылки на остальную документацию: +- [Контроллеры](docs/controllers.md) \ No newline at end of file diff --git a/assets/template_msg.txt b/assets/template_msg.txt new file mode 100644 index 0000000..6da9480 --- /dev/null +++ b/assets/template_msg.txt @@ -0,0 +1,5 @@ +New feedback: +Host: {{ .Host }} +Contact: {{ .Contact }} +WhoAmi: {{ .WhoAmi }} +CreatedAt: {{ .CreatedAt.Format "Mon, 02 Jan 2006 15:04:05 MST" }} \ No newline at end of file diff --git a/cmd/feedback/main.go b/cmd/feedback/main.go new file mode 100644 index 0000000..c0526ad --- /dev/null +++ b/cmd/feedback/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "log" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/app" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/config" +) + +func main() { + cfg, err := config.NewConfig("test.env") + if err != nil { + log.Fatal("can't load config: ", err.Error()) + } + + app.Run(cfg) +} diff --git a/docs/controllers.md b/docs/controllers.md new file mode 100644 index 0000000..4aed918 --- /dev/null +++ b/docs/controllers.md @@ -0,0 +1,21 @@ +# Контроллеры + +## Feedback +Отвечает за работу формы обратной связи. Совмещает в себе описание как транспортного слоя, так и бизнес логики. + +- `/callme` - Метод для отправки фидбэка + - **Method:** GET + - **Authorization:** none + - **Request Example** + ```json + { + "contact": "some contact", + "whoami": "some whoami" + } + ``` + _contact_: Required + + _whoami_: Required + + - **Response Example** + `Status Code: 200 OK` diff --git a/docs/openapi.json b/docs/openapi.json new file mode 100644 index 0000000..7432072 --- /dev/null +++ b/docs/openapi.json @@ -0,0 +1,106 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Сервис формы обратной связи шаблонизатора", + "description": "Сервис для получения обратной связи. Отправляет все запросы в телеграм канал.", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://localhost" + } + ], + "paths": { + "/callme": { + "get": { + "description": "Метод для отправки фидбэка", + "requestBody": { + "$ref": "#/components/requestBodies/Feedback" + }, + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ErrorValidate" + } + } + } + } + }, + "429": { + "description": "Too many requests" + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Service unavailable" + } + } + } + } + }, + "components": { + "schemas": { + "Feedback": { + "type": "object", + "properties": { + "contact": { + "type": "string", + "example": "some contact" + }, + "whoami": { + "type": "string", + "example": "test" + } + } + }, + "Error": { + "type": "string", + "example": "some error" + }, + "ErrorValidate": { + "type": "object", + "properties": { + "field": { + "type": "string", + "example": "contact" + }, + "tag": { + "type": "string", + "example": "required" + }, + "value": { + "type": "string", + "example": "" + } + } + } + }, + "requestBodies": { + "Feedback": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Feedback" + } + } + } + } + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8dfaa8d --- /dev/null +++ b/go.mod @@ -0,0 +1,42 @@ +module penahub.gitlab.yandexcloud.net/backend/templategen_feedback + +go 1.19 + +require ( + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/caarlos0/env v3.5.0+incompatible // indirect + github.com/caarlos0/env/v8 v8.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.12.0 // indirect + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 // indirect + github.com/gofiber/contrib/fiberzap v1.0.2 // indirect + github.com/gofiber/fiber/v2 v2.44.0 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/klauspost/compress v1.16.4 // indirect + github.com/leodido/go-urn v1.2.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/philhofer/fwd v1.1.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.4 // indirect + github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 // indirect + github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect + github.com/stretchr/objx v0.5.0 // indirect + github.com/stretchr/testify v1.8.2 // indirect + github.com/tinylib/msgp v1.1.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.45.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + go.etcd.io/bbolt v1.3.7 // indirect + go.uber.org/atomic v1.10.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.24.0 // indirect + golang.org/x/crypto v0.7.0 // indirect + golang.org/x/sys v0.7.0 // indirect + golang.org/x/text v0.8.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c02db1c --- /dev/null +++ b/go.sum @@ -0,0 +1,137 @@ +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/caarlos0/env v3.5.0+incompatible h1:Yy0UN8o9Wtr/jGHZDpCBLpNrzcFLLM2yixi/rBrKyJs= +github.com/caarlos0/env v3.5.0+incompatible/go.mod h1:tdCsowwCzMLdkqRYDlHpZCp2UooDD3MspDBjZ2AD02Y= +github.com/caarlos0/env/v8 v8.0.0 h1:POhxHhSpuxrLMIdvTGARuZqR4Jjm8AYmoi/JKlcScs0= +github.com/caarlos0/env/v8 v8.0.0/go.mod h1:7K4wMY9bH0esiXSSHlfHLX5xKGQMnkH5Fk4TDSSSzfo= +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/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.12.0 h1:E4gtWgxWxp8YSxExrQFv5BpCahla0PVF2oTTEYaWQGI= +github.com/go-playground/validator/v10 v10.12.0/go.mod h1:hCAPuzYvKdP33pxWa+2+6AIKXEKqjIUyqsNCtbsSJrA= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/gofiber/contrib/fiberzap v1.0.2 h1:EQwhggtszVfIdBeXxN9Xrmld71es34Ufs+ef8VMqZxc= +github.com/gofiber/contrib/fiberzap v1.0.2/go.mod h1:jGO8BHU4gRI9U0JtM6zj2CIhYfgVmW5JxziN8NTgVwE= +github.com/gofiber/fiber/v2 v2.44.0 h1:Z90bEvPcJM5GFJnu1py0E1ojoerkyew3iiNJ78MQCM8= +github.com/gofiber/fiber/v2 v2.44.0/go.mod h1:VTMtb/au8g01iqvHyaCzftuM/xmZgKOZCtFzz6CdV9w= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY= +github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.16.4 h1:91KN02FnsOYhuunwU4ssRe8lc2JosWmizWa91B5v1PU= +github.com/klauspost/compress v1.16.4/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/leodido/go-urn v1.2.2 h1:7z68G0FCGvDk646jz1AelTYNYWrTNm0bEcFAo147wt4= +github.com/leodido/go-urn v1.2.2/go.mod h1:kUaIbLZWttglzwNuG0pgsh5vuV6u2YcGBYz1hIPjtOQ= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= +github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= +github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= +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/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rwtodd/Go.Sed v0.0.0-20210816025313-55464686f9ef/go.mod h1:8AEUvGVi2uQ5b24BIhcr0GCcpd/RNAFWaN2CJFrWIIQ= +github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 h1:rmMl4fXJhKMNWl+K+r/fq4FbbKI+Ia2m9hYBLm2h4G4= +github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94/go.mod h1:90zrgN3D/WJsDd1iXHT96alCoN2KJo6/4x1DZC3wZs8= +github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4= +github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk= +github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw= +github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= +github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.45.0 h1:zPkkzpIn8tdHZUrVa6PzYd0i5verqiPSkgTd3bSUcpA= +github.com/valyala/fasthttp v1.45.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ= +go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= +go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +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.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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/app/app_feedback.go b/internal/app/app_feedback.go new file mode 100644 index 0000000..7ec6498 --- /dev/null +++ b/internal/app/app_feedback.go @@ -0,0 +1,78 @@ +package app + +import ( + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "go.etcd.io/bbolt" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "log" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/config" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/initialize" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/server" +) + +func Run(cfg *config.Config) { + cfgLogger := zap.NewDevelopmentConfig() + cfgLogger.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + cfgLogger.EncoderConfig.ConsoleSeparator = " " + + logger, err := cfgLogger.Build() + if err != nil { + panic(err) + } + + logger.Info("RUN", zap.Any("ENV", cfg)) + + db, err := bbolt.Open("bolt.db", 0666, nil) + if err != nil { + logger.Fatal("BoltDB", zap.Error(err)) + } + + bot, err := tgbotapi.NewBotAPI(cfg.TelegramToken) + if err != nil { + logger.Fatal("TelegramBotApi", zap.Error(err)) + } + + repositories, err := initialize.NewRepositories(logger, db) + if err != nil { + logger.Fatal("BoltDB", zap.Error(err)) + } + + clients := initialize.NewClients(logger, bot, cfg.TelegramChannelID, cfg.TemplatePath) + + bot.GetUpdatesChan(tgbotapi.UpdateConfig{ + Offset: 0, + Limit: 0, + Timeout: 0, + AllowedUpdates: nil, + }) + + //err = clients.Telegram.SendMessage("Bot started") + + if err != nil { + logger.Fatal("TelegramBot", zap.Error(err)) + } + + controllers := initialize.NewControllers(logger, repositories.Feedback, clients.Telegram) + + if err = controllers.Feedback.WarmUpService(); err != nil { + log.Fatal("Controllers.Feedback", zap.Error(err)) + } + + logger.Info("Feedback service", zap.String("status", "warmed up")) + + go controllers.Feedback.RunService() + + logger.Info("Feedback service", zap.String("status", "started")) + + httpSrv := server.NewHTTP(cfg, logger).Register(controllers.List()...) + + go func() { + err := httpSrv.Start() + if err != nil { + logger.Fatal("CanNotServe", zap.Error(err)) + } + }() + + gracefulShutdown(logger, httpSrv, db, controllers) +} diff --git a/internal/app/shutdown.go b/internal/app/shutdown.go new file mode 100644 index 0000000..f94af4a --- /dev/null +++ b/internal/app/shutdown.go @@ -0,0 +1,33 @@ +package app + +import ( + "go.etcd.io/bbolt" + "go.uber.org/zap" + "os" + "os/signal" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/initialize" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/server" + "syscall" +) + +func gracefulShutdown(logger *zap.Logger, httpSrv *server.HTTP, db *bbolt.DB, controllers *initialize.Controllers) { + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) + killSignal := <-interrupt + switch killSignal { + case os.Interrupt: + logger.Info("AppInterrupted") + case syscall.SIGTERM: + logger.Info("AppTerminated") + } + + if err := httpSrv.Stop(); err != nil { + logger.Error("HttpServerShutdown", zap.Error(err)) + } + + if err := db.Close(); err != nil { + logger.Error("BoltDB", zap.Error(err)) + } + + controllers.Feedback.StopService() +} diff --git a/internal/client/telegram.go b/internal/client/telegram.go new file mode 100644 index 0000000..7ed3f67 --- /dev/null +++ b/internal/client/telegram.go @@ -0,0 +1,57 @@ +package client + +import ( + "bytes" + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/models" + "text/template" +) + +type Telegram struct { + logger *zap.Logger + bot *tgbotapi.BotAPI + chatID int64 + templatePath string +} + +func NewTelegram(logger *zap.Logger, bot *tgbotapi.BotAPI, chatID int64, templatePath string) *Telegram { + return &Telegram{logger: logger, bot: bot, chatID: chatID, templatePath: templatePath} +} + +func (t *Telegram) SendFeedback(data *models.Feedback) error { + tpl, err := template.ParseFiles(t.templatePath) + if err != nil { + return err + } + + var text bytes.Buffer + + err = tpl.Execute(&text, data) + if err != nil { + t.logger.Error("ClientTelegram", zap.Error(err)) + return err + } + + msg := tgbotapi.NewMessage(t.chatID, text.String()) + + _, err = t.bot.Send(msg) + if err != nil { + t.logger.Error("ClientTelegram", zap.Error(err)) + return err + } + + return nil +} + +func (t *Telegram) SendMessage(data string) error { + msg := tgbotapi.NewMessage(t.chatID, data) + _, err := t.bot.Send(msg) + + if err != nil { + t.logger.Error("ClientTelegram", zap.Error(err)) + return err + } + + return nil +} diff --git a/internal/client/telegram_test.go b/internal/client/telegram_test.go new file mode 100644 index 0000000..03a1264 --- /dev/null +++ b/internal/client/telegram_test.go @@ -0,0 +1,43 @@ +package client + +import ( + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/stretchr/testify/suite" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/config" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/models" + "testing" +) + +type TelegramTestSuite struct { + suite.Suite + bot *Telegram +} + +func (suite *TelegramTestSuite) SetupSuite() { + cfg, err := config.NewConfig("test.env") + + suite.NoError(err) + logger := zap.NewNop() + + bot, err := tgbotapi.NewBotAPI(cfg.TelegramToken) + suite.NoError(err) + + suite.bot = NewTelegram(logger, bot, cfg.TelegramChannelID, cfg.TemplatePath) +} + +func (suite *TelegramTestSuite) TestTelegram_SendFeedback() { + arg := models.NewFeedback("test.client.telegram.sendFeedback", "suite test", "suite test") + err := suite.bot.SendFeedback(arg) + suite.NoError(err) +} + +func (suite *TelegramTestSuite) TestTelegram_SendMessage() { + arg := "test.client.telegram.sendMessage: test message" + err := suite.bot.SendMessage(arg) + suite.NoError(err) +} + +func TestTelegramTestSuite(t *testing.T) { + suite.Run(t, new(TelegramTestSuite)) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..bea04a8 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,29 @@ +package config + +import ( + "github.com/caarlos0/env/v8" + "github.com/joho/godotenv" + "time" +) + +type Config struct { + TelegramToken string `env:"TELEGRAM_TOKEN,required"` + TelegramChannelID int64 `env:"TELEGRAM_CHANNEL_ID,required"` + TemplatePath string `env:"TEMPLATE_PATH,required"` + HttpRateLimit time.Duration `env:"HTTP_RATE_LIMIT" envDefault:"30s"` + HttpAddress string `env:"HTTP_ADDRESS" envDefault:":80"` +} + +// NewConfig - получить конфигурацию приложения из .env +func NewConfig(file ...string) (*Config, error) { + if err := godotenv.Load(file...); err != nil { + return nil, err + } + + var cfg Config + if err := env.Parse(&cfg); err != nil { + return nil, err + } + + return &cfg, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..cbcd327 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,49 @@ +package config + +import ( + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func TestNewConfig(t *testing.T) { + tests := []struct { + name string + args string + withError bool + want any + }{ + { + name: "equal", + args: "test.env", + want: &Config{ + TelegramToken: "6016088135:AAH3KFBfsi5ivoS0bF9f0p6j28HNuwoBNn0", + TelegramChannelID: -1001957127019, + TemplatePath: "assets/template_msg.txt", + HttpRateLimit: 30 * time.Second, + HttpAddress: ":80", + }, + }, + { + name: "error no file", + args: "not-found.env", + withError: true, + want: "open no-test.env: The system cannot find the file specified.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewConfig(tt.args) + + if tt.withError { + assert.Equal(t, err.Error(), tt.want) + } else { + if assert.NoError(t, err) { + assert.Equal(t, tt.want, got) + } + } + }) + } + +} diff --git a/internal/controller/controller.go b/internal/controller/controller.go new file mode 100644 index 0000000..1b4dd47 --- /dev/null +++ b/internal/controller/controller.go @@ -0,0 +1,37 @@ +package controller + +import ( + "github.com/go-playground/validator/v10" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/models" + "reflect" +) + +var validate = validator.New() + +// validateStruct - возвращает строку с ошибкой, если структура не прошла валидацию +func validateStruct(s any) []*models.RespErrorValidate { + err := validate.Struct(s) + + var errorsValidate []*models.RespErrorValidate + if err != nil { + for _, err := range err.(validator.ValidationErrors) { + field := err.Field() + + r, _ := reflect.TypeOf(s).Elem().FieldByName(err.Field()) + if queryTag := r.Tag.Get("query"); queryTag != "" { + field = queryTag + } + if jsonTag := r.Tag.Get("json"); jsonTag != "" { + field = jsonTag + } + + errorsValidate = append(errorsValidate, &models.RespErrorValidate{ + Field: field, + Tag: err.Tag(), + Value: err.Param(), + }) + } + } + + return errorsValidate +} diff --git a/internal/controller/controller_test.go b/internal/controller/controller_test.go new file mode 100644 index 0000000..afd4661 --- /dev/null +++ b/internal/controller/controller_test.go @@ -0,0 +1,44 @@ +package controller + +import ( + "github.com/stretchr/testify/assert" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/models" + "testing" +) + +type testStruct struct { + Name string `json:"name" validate:"required"` +} + +func Test_validateStruct(t *testing.T) { + tests := []struct { + name string + args *testStruct + want []*models.RespErrorValidate + }{ + { + name: "filled struct", + args: &testStruct{Name: "John Doe"}, + want: nil, + }, + { + name: "struct with empty required", + args: &testStruct{ + Name: "", + }, + want: []*models.RespErrorValidate{ + { + Field: "name", + Tag: "required", + Value: "", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := validateStruct(tt.args) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/controller/feedback.go b/internal/controller/feedback.go new file mode 100644 index 0000000..a17ae87 --- /dev/null +++ b/internal/controller/feedback.go @@ -0,0 +1,147 @@ +package controller + +import ( + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/client" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/models" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/repository" +) + +// FeedbackController - контроллер формы обратной связи +type FeedbackController struct { + logger *zap.Logger + repository *repository.FeedbackRepository + telegram *client.Telegram + queue *FeedbackQueue + interrupter chan bool // Канал для прерывания работы сервиса контроллера +} + +// NewFeedbackController - создать контроллер формы обратной связи +func NewFeedbackController( + logger *zap.Logger, + rep *repository.FeedbackRepository, + tg *client.Telegram) *FeedbackController { + return &FeedbackController{logger: logger, repository: rep, telegram: tg, queue: NewFeedbackQueue(), interrupter: make(chan bool, 1)} +} + +// Register - регистрирует путь в fiber. +// +// Method: GET +// Path: /callme +// Name: callMe +func (r *FeedbackController) Register() (method, path, name string, handler fiber.Handler) { + return "GET", "/callme", "callMe", r.Handler +} + +// Handler - Метод для отправки фидбэка. Складывает запрос в boltdb и отправляет на обработку в очередь +// +// Request: models.ReqFeedback +// +// Responses: +// Success +// Status: 200 +// Body: nil +// +// Bad request - parsing error +// Status: 400 +// Body: error +// +// Bad request - validation error +// Status: 400 +// Body: { {field: string, tag: string, value: string}, ... } +// +// Internal server error - repository insert error +// Status: 500 +// Body: error +// +// Service Unavailable - enqueue error +// Status: 503 +// Body: nil +func (r *FeedbackController) Handler(c *fiber.Ctx) error { + var req models.ReqFeedback + + err := c.BodyParser(&req) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + errValidate := validateStruct(&req) + if errValidate != nil { + return c.Status(fiber.StatusBadRequest).JSON(errValidate) + } + + feedback := models.NewFeedback(c.Hostname(), req.Contact, req.WhoAmi) + + // складываем в BoltDB на безопасное хранение + err = r.repository.Insert(feedback) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + // отправляем в очередь сервиса + if !r.queue.Enqueue(feedback) { + return fiber.NewError(fiber.StatusServiceUnavailable) + } + + return c.SendStatus(fiber.StatusOK) +} + +// WarmUpService - прогревает сервис контроллера формы обратной связи перед запуском. Отправляет все фибдбэки из репозитория клиенту +func (r *FeedbackController) WarmUpService() error { + // достаем из BoltDB записи + tasks, err := r.repository.GetAll() + + if err != nil { + return err + } + + for _, task := range tasks { + _ = r.do(task) + } + + return nil +} + +// RunService - запуск сервиса контроллера формы обратной связи +func (r *FeedbackController) RunService() { + // запускаем цикл проверки очереди + for { + if r.queue.Len() > 0 { + task := r.queue.Dequeue() + + if err := r.do(task); err != nil { + continue + } + } + + select { + case <-r.interrupter: + break + default: + continue + } + } +} + +// do - отправляет фидбэк в клиент и удаляет его из репозитория +func (r *FeedbackController) do(task *models.Feedback) error { + if task != nil { + if err := r.telegram.SendFeedback(task); err != nil { + r.logger.Error("CanNotSendFeedback", zap.Error(err)) + return err + } + + if err := r.repository.Delete(task.GetID()); err != nil { + r.logger.Error("CanNotDeleteFeedback", zap.Error(err)) + return err + } + } + + return nil +} + +// StopService - остановить сервис контроллера формы обратной связи +func (r *FeedbackController) StopService() { + r.interrupter <- true +} diff --git a/internal/controller/feedback_queue.go b/internal/controller/feedback_queue.go new file mode 100644 index 0000000..3c13c98 --- /dev/null +++ b/internal/controller/feedback_queue.go @@ -0,0 +1,58 @@ +package controller + +import ( + "container/list" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/models" + "sync" +) + +const QueueSize = 100 // Размер очереди + +// FeedbackQueue - FIFO очередь с размером QueueSize +type FeedbackQueue struct { + tasks *list.List + m sync.Mutex +} + +// NewFeedbackQueue - создать новую очередь +func NewFeedbackQueue() *FeedbackQueue { + return &FeedbackQueue{tasks: list.New(), m: sync.Mutex{}} +} + +// Enqueue - добавить элемент в очередь. Возвращает true если очередь не переполнена и false в ином случае +func (q *FeedbackQueue) Enqueue(record *models.Feedback) bool { + q.m.Lock() + + if q.tasks.Len() >= QueueSize { + q.m.Unlock() + return false + } + q.tasks.PushBack(record) + q.m.Unlock() + + return true +} + +// Dequeue - взять элемент из очереди +func (q *FeedbackQueue) Dequeue() *models.Feedback { + q.m.Lock() + + value := q.tasks.Front() + if value == nil { + q.m.Unlock() + return nil + } + + q.tasks.Remove(value) + q.m.Unlock() + + return value.Value.(*models.Feedback) +} + +// Len - получить длину очереди +func (q *FeedbackQueue) Len() int { + q.m.Lock() + l := q.tasks.Len() + q.m.Unlock() + return l +} diff --git a/internal/controller/feedback_queue_test.go b/internal/controller/feedback_queue_test.go new file mode 100644 index 0000000..41f42cd --- /dev/null +++ b/internal/controller/feedback_queue_test.go @@ -0,0 +1,53 @@ +package controller + +import ( + "github.com/stretchr/testify/assert" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/models" + "testing" +) + +func TestFeedbackQueue_Enqueue(t *testing.T) { + args := models.NewFeedback("host", "contact", "whoami") + + queue := NewFeedbackQueue() + got := queue.Enqueue(args) + + assert.True(t, got) +} + +func TestFeedbackQueue_EnqueueFilled(t *testing.T) { + args := models.NewFeedback("host", "contact", "whoami") + + queue := NewFeedbackQueue() + for i := 0; i <= QueueSize-1; i++ { + queue.Enqueue(args) + } + + got := queue.Enqueue(args) + + assert.False(t, got) +} + +func TestFeedbackQueue_Dequeue(t *testing.T) { + args := models.NewFeedback("host", "contact", "whoami") + want := args + + queue := NewFeedbackQueue() + + if assert.True(t, queue.Enqueue(args)) { + got := queue.Dequeue() + assert.Equal(t, want, got) + } +} + +func TestFeedbackQueue_Len(t *testing.T) { + args := models.NewFeedback("host", "contact", "whoami") + want := 1 + + queue := NewFeedbackQueue() + if assert.True(t, queue.Enqueue(args)) { + got := queue.Len() + assert.Equal(t, want, got) + } + +} diff --git a/internal/controller/feedback_test.go b/internal/controller/feedback_test.go new file mode 100644 index 0000000..f6b004b --- /dev/null +++ b/internal/controller/feedback_test.go @@ -0,0 +1,131 @@ +package controller + +import ( + "bytes" + "encoding/json" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/suite" + "go.etcd.io/bbolt" + "go.uber.org/zap" + "net/http" + "os" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/client" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/config" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/models" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/repository" + "testing" +) + +type FeedbackTestSuite struct { + controller *FeedbackController + db *bbolt.DB + suite.Suite +} + +func (suite *FeedbackTestSuite) SetupSuite() { + cfg, err := config.NewConfig("test.env") + suite.NoError(err) + + logger := zap.NewNop() + + db, err := bbolt.Open("test.db", 0666, nil) + suite.NoError(err) + suite.db = db + + repFeedback := repository.NewFeedback(logger, db) + err = repFeedback.CreateBucket() + suite.NoError(err) + + bot, err := tgbotapi.NewBotAPI(cfg.TelegramToken) + suite.NoError(err) + + tgClient := client.NewTelegram(logger, bot, cfg.TelegramChannelID, cfg.TemplatePath) + + suite.controller = NewFeedbackController(logger, repFeedback, tgClient) +} + +func (suite *FeedbackTestSuite) TearDownSuite() { + suite.NoError(suite.db.Close()) + suite.NoError(os.Remove("test.db")) +} + +func (suite *FeedbackTestSuite) TestFeedback_Register() { + wantMethod := "GET" + wantPath := "/callme" + + gotMethod, gotPath, _, _ := suite.controller.Register() + + suite.Equal(wantMethod, gotMethod) + suite.Equal(wantPath, gotPath) +} + +func (suite *FeedbackTestSuite) TestFeedback_Handler() { + tests := []struct { + name string + args *models.ReqFeedback + want int + }{ + { + name: "status 200", + args: &models.ReqFeedback{ + Contact: "test.controller.feedback.handler", + WhoAmi: "suite test", + }, + want: fiber.StatusOK, + }, + { + name: "status 400", + args: &models.ReqFeedback{}, + want: fiber.StatusBadRequest, + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + app := fiber.New() + + method, path, name, handler := suite.controller.Register() + app.Add(method, path, handler).Name(name) + + b, err := json.Marshal(tt.args) + suite.NoError(err) + + req, err := http.NewRequest("GET", "/callme", bytes.NewBuffer(b)) + suite.NoError(err) + + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req, 10) + suite.NoError(err) + + suite.Equal(tt.want, resp.StatusCode) + }) + } +} + +func (suite *FeedbackTestSuite) TestFeedback_WarmUpService() { + err := suite.controller.WarmUpService() + suite.NoError(err) +} + +func (suite *FeedbackTestSuite) TestFeedback_do() { + args := models.NewFeedback("test.controller.feedback.do", "suite test", "suite test") + err := suite.controller.do(args) + suite.NoError(err) +} + +func (suite *FeedbackTestSuite) TestFeedback_StopService() { + done := make(chan bool) + go func() { + go suite.controller.RunService() + done <- true + }() + + suite.controller.StopService() + suite.True(<-done) +} + +func TestFeedbackTestSuite(t *testing.T) { + suite.Run(t, new(FeedbackTestSuite)) +} diff --git a/internal/initialize/client.go b/internal/initialize/client.go new file mode 100644 index 0000000..bbe583b --- /dev/null +++ b/internal/initialize/client.go @@ -0,0 +1,15 @@ +package initialize + +import ( + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/client" +) + +type Clients struct { + Telegram *client.Telegram +} + +func NewClients(logger *zap.Logger, bot *tgbotapi.BotAPI, chatID int64, templatePath string) *Clients { + return &Clients{Telegram: client.NewTelegram(logger, bot, chatID, templatePath)} +} diff --git a/internal/initialize/controllers.go b/internal/initialize/controllers.go new file mode 100644 index 0000000..c7a5fb7 --- /dev/null +++ b/internal/initialize/controllers.go @@ -0,0 +1,41 @@ +package initialize + +import ( + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/client" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/controller" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/repository" + "reflect" +) + +type Controller interface { + Register() (method, path, name string, handler fiber.Handler) + Handler(c *fiber.Ctx) error +} + +type Controllers struct { + Feedback *controller.FeedbackController +} + +// List - возвращает список контроллеров +func (c *Controllers) List() []Controller { + fields := reflect.ValueOf(c).Elem() + + var controllers []Controller + for i := 0; i < fields.NumField(); i++ { + vf := fields.Field(i) + + if vf.Type().Implements(reflect.TypeOf((*Controller)(nil)).Elem()) { + controllers = append(controllers, vf.Interface().(Controller)) + } + } + + return controllers +} + +func NewControllers(logger *zap.Logger, rep *repository.FeedbackRepository, tg *client.Telegram) *Controllers { + return &Controllers{ + Feedback: controller.NewFeedbackController(logger, rep, tg), + } +} diff --git a/internal/initialize/repositories.go b/internal/initialize/repositories.go new file mode 100644 index 0000000..370693d --- /dev/null +++ b/internal/initialize/repositories.go @@ -0,0 +1,20 @@ +package initialize + +import ( + "go.etcd.io/bbolt" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/repository" +) + +type Repositories struct { + Feedback *repository.FeedbackRepository +} + +func NewRepositories(logger *zap.Logger, db *bbolt.DB) (*Repositories, error) { + feedback := repository.NewFeedback(logger, db) + if err := feedback.CreateBucket(); err != nil { + return nil, err + } + + return &Repositories{Feedback: feedback}, nil +} diff --git a/internal/models/feedback.go b/internal/models/feedback.go new file mode 100644 index 0000000..2a47574 --- /dev/null +++ b/internal/models/feedback.go @@ -0,0 +1,24 @@ +package models + +import "time" + +type Feedback struct { + Host string // Хост отправки + Contact string + WhoAmi string + CreatedAt time.Time // Время создания +} + +// GetID - возвращает идентификатор. Идентификатором является время форматированное в time.StampNano +func (f *Feedback) GetID() string { + return f.CreatedAt.Format(time.StampNano) +} + +type ReqFeedback struct { + Contact string `json:"contact" validate:"required"` + WhoAmi string `json:"whoAmi" validate:"required"` +} + +func NewFeedback(host string, contact string, whoAmi string) *Feedback { + return &Feedback{Host: host, Contact: contact, WhoAmi: whoAmi, CreatedAt: time.Now()} +} diff --git a/internal/models/validate.go b/internal/models/validate.go new file mode 100644 index 0000000..88d6db9 --- /dev/null +++ b/internal/models/validate.go @@ -0,0 +1,7 @@ +package models + +type RespErrorValidate struct { + Field string `json:"field"` + Tag string `json:"tag"` + Value string `json:"value"` +} diff --git a/internal/repository/feedback.go b/internal/repository/feedback.go new file mode 100644 index 0000000..8102b93 --- /dev/null +++ b/internal/repository/feedback.go @@ -0,0 +1,115 @@ +package repository + +import ( + "encoding/json" + "go.etcd.io/bbolt" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/models" + "time" +) + +type FeedbackRepository struct { + logger *zap.Logger + db *bbolt.DB +} + +var feedbackBucket = []byte("feedbacks") + +func NewFeedback(logger *zap.Logger, db *bbolt.DB) *FeedbackRepository { + r := &FeedbackRepository{logger: logger, db: db} + return r +} + +// CreateBucket - создать корзину +func (r *FeedbackRepository) CreateBucket() error { + fn := func(tx *bbolt.Tx) error { + _, err := tx.CreateBucketIfNotExists(feedbackBucket) + if r.err(err) { + return err + } + return nil + } + + return r.db.Update(fn) +} + +// Insert - вставить запись +func (r *FeedbackRepository) Insert(record *models.Feedback) error { + fn := func(tx *bbolt.Tx) error { + b := tx.Bucket(feedbackBucket) + + encoded, err := json.Marshal(record) + + if r.err(err) { + return err + } + err = b.Put([]byte(record.CreatedAt.Format(time.StampNano)), encoded) + + if r.err(err) { + return err + } + return nil + } + + return r.db.Update(fn) +} + +// GetAll - получить все записи +func (r *FeedbackRepository) GetAll() ([]*models.Feedback, error) { + var result []*models.Feedback + fn := func(tx *bbolt.Tx) error { + b := tx.Bucket(feedbackBucket) + + return b.ForEach(func(_, v []byte) error { + var data models.Feedback + + err := json.Unmarshal(v, &data) + if r.err(err) { + return err + } + + result = append(result, &data) + return nil + }) + } + + return result, r.db.View(fn) +} + +// Get - получить запись по id +func (r *FeedbackRepository) Get(id string) (*models.Feedback, error) { + var data models.Feedback + fn := func(tx *bbolt.Tx) error { + b := tx.Bucket(feedbackBucket) + body := b.Get([]byte(id)) + + err := json.Unmarshal(body, &data) + if r.err(err) { + return err + } + + return nil + } + + return &data, r.db.View(fn) +} + +// Delete - удалить запись по id +func (r *FeedbackRepository) Delete(id string) error { + fn := func(tx *bbolt.Tx) error { + b := tx.Bucket(feedbackBucket) + return b.Delete([]byte(id)) + } + + return r.db.Update(fn) +} + +// err - логгирует если есть ошибка и возвращает true +func (r *FeedbackRepository) err(err error) bool { + if err != nil { + r.logger.Error("RepositoryFeedback", zap.Error(err)) + return true + } + + return false +} diff --git a/internal/repository/feedback_test.go b/internal/repository/feedback_test.go new file mode 100644 index 0000000..9462991 --- /dev/null +++ b/internal/repository/feedback_test.go @@ -0,0 +1,126 @@ +package repository + +import ( + "errors" + "fmt" + "github.com/stretchr/testify/suite" + "go.etcd.io/bbolt" + "go.uber.org/zap" + "os" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/models" + "testing" +) + +type FeedbackTestSuite struct { + args *models.Feedback + repository *FeedbackRepository + db *bbolt.DB + suite.Suite +} + +func (suite *FeedbackTestSuite) SetupSuite() { + suite.args = models.NewFeedback("test.repository.feedback.insert", "suite test", "suite test") +} + +func (suite *FeedbackTestSuite) SetupTest() { + logger := zap.NewNop() + db, err := bbolt.Open("test.db", 0666, nil) + + if suite.NoError(err) { + suite.db = db + suite.repository = NewFeedback(logger, db) + err = suite.repository.CreateBucket() + if suite.NoError(err) { + err = suite.repository.Insert(suite.args) + suite.NoError(err) + } + } +} + +func (suite *FeedbackTestSuite) TearDownTest() { + suite.NoError(suite.db.Close()) + suite.NoError(os.Remove("test.db")) +} + +func (suite *FeedbackTestSuite) TestFeedback_CreateBucket() { + err := suite.repository.CreateBucket() + suite.NoError(err) +} + +func (suite *FeedbackTestSuite) TestFeedback_Insert() { + err := suite.repository.Insert(suite.args) + if suite.NoError(err) { + got, err := suite.repository.Get(suite.args.GetID()) + + if suite.NoError(err) { + suite.Equal(suite.args.GetID(), got.GetID()) + suite.Equal(suite.args.Contact, got.Contact) + suite.Equal(suite.args.WhoAmi, got.WhoAmi) + suite.Equal(suite.args.Host, got.Host) + suite.True(suite.args.CreatedAt.Equal(got.CreatedAt)) + } + } +} + +func (suite *FeedbackTestSuite) TestFeedback_GetAll() { + gotArr, err := suite.repository.GetAll() + got := gotArr[0] + if suite.NoError(err) { + suite.Equal(suite.args.GetID(), got.GetID()) + suite.Equal(suite.args.Contact, got.Contact) + suite.Equal(suite.args.WhoAmi, got.WhoAmi) + suite.Equal(suite.args.Host, got.Host) + suite.True(suite.args.CreatedAt.Equal(got.CreatedAt)) + } +} + +func (suite *FeedbackTestSuite) TestFeedback_Get() { + got, err := suite.repository.Get(suite.args.GetID()) + + if suite.NoError(err) { + suite.Equal(suite.args.GetID(), got.GetID()) + suite.Equal(suite.args.Contact, got.Contact) + suite.Equal(suite.args.WhoAmi, got.WhoAmi) + suite.Equal(suite.args.Host, got.Host) + suite.True(suite.args.CreatedAt.Equal(got.CreatedAt)) + } +} + +func (suite *FeedbackTestSuite) TestFeedback_Delete() { + err := suite.repository.Delete(suite.args.GetID()) + + if suite.NoError(err) { + got, err := suite.repository.Get(suite.args.GetID()) + fmt.Println("got:", got, err) + } +} + +func (suite *FeedbackTestSuite) TestFeedback_err() { + tests := []struct { + name string + args error + want bool + }{ + { + name: "no error", + args: nil, + want: false, + }, + { + name: "have error", + args: errors.New("test.repository.feedback.err"), + want: true, + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + got := suite.repository.err(tt.args) + suite.Equal(tt.want, got) + }) + } +} + +func TestFeedbackTestSuite(t *testing.T) { + suite.Run(t, new(FeedbackTestSuite)) +} diff --git a/internal/server/http.go b/internal/server/http.go new file mode 100644 index 0000000..0b9dfdc --- /dev/null +++ b/internal/server/http.go @@ -0,0 +1,55 @@ +package server + +import ( + "github.com/gofiber/contrib/fiberzap" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/limiter" + "github.com/gofiber/fiber/v2/middleware/recover" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/config" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/initialize" +) + +type HTTP struct { + fiber *fiber.App + cfg *config.Config + logger *zap.Logger +} + +// NewHTTP - задает конфиги и миддлвейры для http сервера +func NewHTTP(cfg *config.Config, logger *zap.Logger) *HTTP { + srv := fiber.New(fiber.Config{ + AppName: "TemplateGenerator feedback", + ErrorHandler: fiber.DefaultErrorHandler, + }) + + srv.Use( + recover.New(recover.Config{EnableStackTrace: true}), + fiberzap.New(fiberzap.Config{Logger: logger}), + limiter.New(limiter.Config{ + Max: 1, + Expiration: cfg.HttpRateLimit, + }), + ) + + return &HTTP{fiber: srv, cfg: cfg, logger: logger} +} + +// Register - автоматически регистрирует все контроллеры +func (srv *HTTP) Register(controllers ...initialize.Controller) *HTTP { + for _, controller := range controllers { + method, path, name, handler := controller.Register() + srv.fiber.Add(method, path, handler).Name(name) + } + return srv +} + +// Start - запускает http сервер +func (srv *HTTP) Start() error { + return srv.fiber.Listen(srv.cfg.HttpAddress) +} + +// Stop - останавливает http сервер +func (srv *HTTP) Stop() error { + return srv.fiber.Shutdown() +} diff --git a/internal/server/http_test.go b/internal/server/http_test.go new file mode 100644 index 0000000..2f49e0e --- /dev/null +++ b/internal/server/http_test.go @@ -0,0 +1,107 @@ +package server + +import ( + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "go.uber.org/zap" + "net/http" + "penahub.gitlab.yandexcloud.net/backend/templategen_feedback/internal/config" + "testing" + "time" +) + +type HttpTestSuite struct { + srv *HTTP + suite.Suite +} + +type MockController struct { + mock.Mock +} + +func (m *MockController) Register() (method, path, name string, handler fiber.Handler) { + return "GET", "/callme", "testCallMe", m.Handler +} + +func (m *MockController) Handler(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) +} + +func (suite *HttpTestSuite) SetupSuite() { + cfg, err := config.NewConfig("test.env") + + suite.NoError(err) + logger := zap.NewNop() + + srv := NewHTTP(cfg, logger) + srv.fiber.Config() + suite.srv = srv +} + +func (suite *HttpTestSuite) TearDownSuite() { + err := suite.srv.Stop() + suite.NoError(err) +} + +func (suite *HttpTestSuite) TestHttp_Register() { + controller := new(MockController) + suite.srv.Register(controller) + + got := suite.srv.fiber.GetRoute("testCallMe") + + suite.Equal("/callme", got.Path) +} + +func (suite *HttpTestSuite) TestHttp_StartAndStop() { + gotStart := false + gotStop := false + + suite.srv.fiber.Hooks().OnListen(func() error { + gotStart = true + return nil + }) + + suite.srv.fiber.Hooks().OnShutdown(func() error { + gotStop = true + return nil + }) + + go func() { + err := suite.srv.Start() + suite.NoError(err) + }() + + time.Sleep(500 * time.Millisecond) + + suite.True(gotStart) + + err := suite.srv.fiber.Shutdown() + + if suite.NoError(err) { + time.Sleep(500 * time.Millisecond) + suite.True(gotStop) + } +} + +func (suite *HttpTestSuite) TestHttp_Limiter() { + controller := new(MockController) + suite.srv.Register(controller) + + req, err := http.NewRequest("GET", "/callme", nil) + suite.NoError(err) + + gotOne, err := suite.srv.fiber.Test(req) + suite.NoError(err) + + suite.Equal(fiber.StatusOK, gotOne.StatusCode) + + gotTwo, err := suite.srv.fiber.Test(req) + suite.NoError(err) + + suite.Equal(fiber.StatusTooManyRequests, gotTwo.StatusCode) +} + +func TestHttpTestSuite(t *testing.T) { + suite.Run(t, new(HttpTestSuite)) +}