From aaec8ec08da7e285d1663f3ee35bdbded9920a09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?c=C4=83t=C4=83lin?= Date: Mon, 4 Dec 2023 22:13:37 +0100 Subject: [PATCH] wip: add api auth and /resume endpoint --- .gitignore | 287 ++------------------- README.md | 1 + apischema/openapi.yaml | 260 +++++++++++++++++++ cmd/app/main.go | 9 + configs/example.db.json | 110 +++++++++ internal/app/app.go | 74 ++++++ internal/delivery/http/handler.go | 43 ++++ internal/delivery/http/middleware.go | 20 ++ internal/delivery/http/v1/handler.go | 25 ++ internal/delivery/http/v1/middleware.go | 30 +++ internal/delivery/http/v1/ping.go | 12 + internal/delivery/http/v1/response.go | 19 ++ internal/delivery/http/v1/resume.go | 24 ++ internal/delivery/http/v1/usage.go | 25 ++ internal/domain/resume.go | 316 ++++++++++++++++++++++++ internal/repo/repos.go | 19 ++ internal/repo/resumes.go | 19 ++ internal/server/server.go | 32 +++ internal/service/resume.go | 20 ++ internal/service/service.go | 27 ++ pkg/limiter/limiter.go | 100 ++++++++ pkg/logger/logger.go | 8 + pkg/logger/logrus.go | 35 +++ 23 files changed, 1244 insertions(+), 271 deletions(-) create mode 100644 apischema/openapi.yaml create mode 100644 cmd/app/main.go create mode 100644 configs/example.db.json create mode 100644 internal/app/app.go create mode 100644 internal/delivery/http/handler.go create mode 100644 internal/delivery/http/middleware.go create mode 100644 internal/delivery/http/v1/handler.go create mode 100644 internal/delivery/http/v1/middleware.go create mode 100644 internal/delivery/http/v1/ping.go create mode 100644 internal/delivery/http/v1/response.go create mode 100644 internal/delivery/http/v1/resume.go create mode 100644 internal/delivery/http/v1/usage.go create mode 100644 internal/domain/resume.go create mode 100644 internal/repo/repos.go create mode 100644 internal/repo/resumes.go create mode 100644 internal/server/server.go create mode 100644 internal/service/resume.go create mode 100644 internal/service/service.go create mode 100644 pkg/limiter/limiter.go create mode 100644 pkg/logger/logger.go create mode 100644 pkg/logger/logrus.go diff --git a/.gitignore b/.gitignore index 31c3aff..e4fb851 100644 --- a/.gitignore +++ b/.gitignore @@ -1,277 +1,22 @@ -# ---> Python -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class +# Ignore everything +* -# C extensions -*.so +# But don't ignore these files... +!/.gitignore -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST +!*.go +!go.sum +!go.mod -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec +!README.md +!LICENSE +!apischema/openapi.yaml +!apischema/resumeschema.json +!configs/config.yml +!configs/example.db.json -# Installer logs -pip-log.txt -pip-delete-this-directory.txt +# !Makefile -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# ---> Linux -*~ - -# temporary files which can be created if a process still has a handle open of a deleted file -.fuse_hidden* - -# KDE directory preferences -.directory - -# Linux trash folder which might appear on any partition or disk -.Trash-* - -# .nfs files are created when an open file is removed but is still being accessed -.nfs* - -# ---> JetBrains -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# AWS User-specific -.idea/**/aws.xml - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# SonarLint plugin -.idea/sonarlint/ - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser - -# ---> Vim -# Swap -[._]*.s[a-v][a-z] -!*.svg # comment out if you don't need vector files -[._]*.sw[a-p] -[._]s[a-rt-v][a-z] -[._]ss[a-gi-z] -[._]sw[a-p] - -# Session -Session.vim -Sessionx.vim - -# Temporary -.netrwhist -*~ -# Auto-generated tag files -tags -# Persistent undo -[._]*.un~ +# ...even if they are in subdirectories +!*/ diff --git a/README.md b/README.md index 058fc9f..3e354f6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # cvvvv +WIP \ No newline at end of file diff --git a/apischema/openapi.yaml b/apischema/openapi.yaml new file mode 100644 index 0000000..d2c1e05 --- /dev/null +++ b/apischema/openapi.yaml @@ -0,0 +1,260 @@ +openapi: 3.1.0 +x-stoplight: + id: 8awcrxb6k3l5z +info: + contact: + email: catalin@roboces.dev + name: cătălin + license: + name: GNU General Public License v3.0 or later + identifier: GPL-3.0-or-later + title: CVVV + description: JSON Resume Schema Explorer + version: 0.1.0 +servers: + - url: 'https://cvvvvv.roboces.dev/api/v1' +security: + - ApiKeyAuth: [] +paths: + /ping: + get: + summary: Healthcheck + description: Check if the server is alive + operationId: getPing + responses: + '200': + description: Ping + content: + schema: + type: string + example: pong + x-stoplight: + id: qrrj5n6zobz3p + /usage: + get: + summary: Get usage + description: Show usage instructions + operationId: getUsage + responses: + '200': + description: Usage + content: + schema: + type: string + example: pong + x-stoplight: + id: xt6go87v6s1rz + /resume: + get: + summary: Get resume + description: Retrieve the full JSON resume + operationId: getResume + security: + - ApiKeyAuth: [] + responses: + '200': + description: Resume + content: + application/json: + schema: + $ref: ./resumeschema.json + x-stoplight: + id: w9tzzy49yf2sw + /resume/basics: + get: + summary: Get basics + description: Retrieve the basics section from the JSON resume + operationId: getResumeBasics + security: + - ApiKeyAuth: [] + responses: + '200': + description: Resume basics + content: + application/json: + schema: + $ref: ./resumeschema.json#/properties/basics + x-stoplight: + id: 40upzt6ummt0i + /resume/work: + get: + summary: Get work + description: Retrieve the work section from the JSON resume + operationId: getResumeWork + security: + - ApiKeyAuth + responses: + '200': + description: Resume work + content: + application/json: + schema: + $ref: ./resumeschema.json#/properties/work + x-stoplight: + id: 310a662ft52e4 + /resume/volunteer: + get: + summary: Get volunteer + description: Retrieve the volunteer section from the JSON resume + operationId: getResumeVolunteer + security: + - ApiKeyAuth: [] + responses: + '200': + description: Resume volunteer + content: + application/json: + schema: + $ref: ./resumeschema.json#/properties/volunteer + x-stoplight: + id: 5hqoylsv7zfu3 + /resume/education: + get: + summary: Get education + description: Retrieve the education section from the JSON resume + operationId: getResumeEducation + security: + - ApiKeyAuth: [] + responses: + '200': + description: Resume education + content: + application/json: + schema: + $ref: ./resumeschema.json#/properties/education + x-stoplight: + id: u4jgzg5nksvps + /resume/awards: + get: + summary: Get awards + description: Retrieve the awards section from the JSON resume + operationId: getResumeAwards + security: + - ApiKeyAuth: [] + responses: + '200': + description: Resume awards + content: + application/json: + schema: + $ref: ./resumeschema.json#/properties/awards + x-stoplight: + id: 0zjhsoqee1cvj + /resume/certificates: + get: + summary: Get certificates + description: Retrieve the certificates section from the JSON resume + operationId: getResumeCertificates + security: + - ApiKeyAuth + responses: + '200': + description: Resume certificates + content: + application/json: + schema: + $ref: ./resumeschema.json#/properties/certificates + x-stoplight: + id: wy17alifr6isl + /resume/publications: + get: + summary: Get publications + description: Retrieve the publications section from the JSON resume + operationId: getResumePublications + security: + - ApiKeyAuth: [] + responses: + '200': + description: Resume publications + content: + application/json: + schema: + $ref: ./resumeschema.json#/properties/publications + x-stoplight: + id: tr5v58qwzhjah + /resume/skills: + get: + summary: Get skills + description: Retrieve the skills section from the JSON resume + operationId: getResumeSkills + security: + - ApiKeyAuth: [] + responses: + '200': + description: Resume skills + content: + application/json: + schema: + $ref: ./resumeschema.json#/properties/skills + x-stoplight: + id: 2of3u7x07mh02 + /resume/languages: + get: + summary: Get languages + description: Retrieve the languages section from the JSON resume + operationId: getResumeLanguages + security: + - ApiKeyAuth: [] + responses: + '200': + description: Resume languages + content: + application/json: + schema: + $ref: ./resumeschema.json#/properties/languages + x-stoplight: + id: 22ndcxgbjnwhh + /resume/interests: + get: + summary: Get interests + description: Retrieve the interests section from the JSON resume + operationId: getResumeInterests + security: + - ApiKeyAuth: [] + responses: + '200': + description: Resume interests + content: + application/json: + schema: + $ref: ./resumeschema.json#/properties/interests + x-stoplight: + id: 7dv8xl1apkm2y + /resume/references: + get: + summary: Get references + description: Retrieve the references section from the JSON resume + operationId: getResumeReferences + security: + - ApiKeyAuth: [] + responses: + '200': + description: Resume references + content: + application/json: + schema: + $ref: ./resumeschema.json#/properties/references + x-stoplight: + id: 2wl5vh02k4jpo + /resume/projects: + get: + summary: Get projects + description: Retrieve the projects section from the JSON resume + operationId: getResumeProjects + security: + - ApiKeyAuth: [] + responses: + '200': + description: Resume projects + content: + application/json: + schema: + $ref: ./resumeschema.json#/properties/projects + x-stoplight: + id: 0ck9me9tw4m3e +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-KEY \ No newline at end of file diff --git a/cmd/app/main.go b/cmd/app/main.go new file mode 100644 index 0000000..f76c573 --- /dev/null +++ b/cmd/app/main.go @@ -0,0 +1,9 @@ +package main + +import "git.roboces.dev/catalin/cvvvvv/internal/app" + +const configsDir = "configs" + +func main() { + app.Run(configsDir) +} diff --git a/configs/example.db.json b/configs/example.db.json new file mode 100644 index 0000000..257e8c5 --- /dev/null +++ b/configs/example.db.json @@ -0,0 +1,110 @@ +{ + "basics": { + "name": "John Doe", + "label": "Programmer", + "image": "", + "email": "john@gmail.com", + "phone": "(912) 555-4321", + "url": "https://johndoe.com", + "summary": "A summary of John Doe…", + "location": { + "address": "2712 Broadway St", + "postalCode": "CA 94115", + "city": "San Francisco", + "countryCode": "US", + "region": "California" + }, + "profiles": [{ + "network": "Twitter", + "username": "john", + "url": "https://twitter.com/john" + }] + }, + "work": [{ + "name": "Company", + "position": "President", + "url": "https://company.com", + "startDate": "2013-01-01", + "endDate": "2014-01-01", + "summary": "Description…", + "highlights": [ + "Started the company" + ] + }], + "volunteer": [{ + "organization": "Organization", + "position": "Volunteer", + "url": "https://organization.com/", + "startDate": "2012-01-01", + "endDate": "2013-01-01", + "summary": "Description…", + "highlights": [ + "Awarded 'Volunteer of the Month'" + ] + }], + "education": [{ + "institution": "University", + "url": "https://institution.com/", + "area": "Software Development", + "studyType": "Bachelor", + "startDate": "2011-01-01", + "endDate": "2013-01-01", + "score": "4.0", + "courses": [ + "DB1101 - Basic SQL" + ] + }], + "awards": [{ + "title": "Award", + "date": "2014-11-01", + "awarder": "Company", + "summary": "There is no spoon." + }], + "certificates": [{ + "name": "Certificate", + "date": "2021-11-07", + "issuer": "Company", + "url": "https://certificate.com" + }], + "publications": [{ + "name": "Publication", + "publisher": "Company", + "releaseDate": "2014-10-01", + "url": "https://publication.com", + "summary": "Description…" + }], + "skills": [{ + "name": "Web Development", + "level": "Master", + "keywords": [ + "HTML", + "CSS", + "JavaScript" + ] + }], + "languages": [{ + "language": "English", + "fluency": "Native speaker" + }], + "interests": [{ + "name": "Wildlife", + "keywords": [ + "Ferrets", + "Unicorns" + ] + }], + "references": [{ + "name": "Jane Doe", + "reference": "Reference…" + }], + "projects": [{ + "name": "Project", + "startDate": "2019-01-01", + "endDate": "2021-01-01", + "description": "Description...", + "highlights": [ + "Won award at AIHacks 2016" + ], + "url": "https://project.com/" + }] +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..49704fb --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,74 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "git.roboces.dev/catalin/cvvvvv/internal/config" + delivery "git.roboces.dev/catalin/cvvvvv/internal/delivery/http" + "git.roboces.dev/catalin/cvvvvv/internal/domain" + "git.roboces.dev/catalin/cvvvvv/internal/repo" + "git.roboces.dev/catalin/cvvvvv/internal/server" + "git.roboces.dev/catalin/cvvvvv/internal/service" + "git.roboces.dev/catalin/cvvvvv/pkg/logger" +) + +func loadResume(path string, resume *domain.Resume) error { + datafile, err := os.Open(path) + if err != nil { + return errors.New("could not open database file") + } + decoder := json.NewDecoder(datafile) + if decoder.Decode(&resume) != nil { + return errors.New("could not decode database") + } + return nil +} + +func Run(configPath string) { + cfg, err := config.Init(configPath) + if err != nil { + logger.Error(err) + return + } + resume := domain.Resume{} + if loadResume(cfg.Database, &resume) != nil { + logger.Error(err) + return + } + repos := repo.NewRepos(resume) + services := service.NewServices(service.Deps{ + Repos: repos, + Config: cfg, + }) + handlers := delivery.NewHandler(services) + srv := server.NewServer(cfg, handlers.Init(cfg)) + + go func() { + if err := srv.Run(); !errors.Is(err, http.ErrServerClosed) { + logger.Errorf("error occurred while running http server: %s\n", err.Error()) + } + }() + logger.Info("Server started") + + // Graceful Shutdown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT) + + <-quit + + const timeout = 5 * time.Second + + ctx, shutdown := context.WithTimeout(context.Background(), timeout) + defer shutdown() + + if err := srv.Stop(ctx); err != nil { + logger.Errorf("failed to stop server: %v", err) + } +} diff --git a/internal/delivery/http/handler.go b/internal/delivery/http/handler.go new file mode 100644 index 0000000..5fc4db7 --- /dev/null +++ b/internal/delivery/http/handler.go @@ -0,0 +1,43 @@ +package http + +import ( + "git.roboces.dev/catalin/cvvvvv/internal/config" + v1 "git.roboces.dev/catalin/cvvvvv/internal/delivery/http/v1" + "git.roboces.dev/catalin/cvvvvv/internal/service" + "git.roboces.dev/catalin/cvvvvv/pkg/limiter" + "github.com/gin-gonic/gin" +) + +type Handler struct { + services *service.Services +} + +func NewHandler(services *service.Services) *Handler { + return &Handler{ + services: services, + } +} + +func (h *Handler) Init(cfg *config.Config) *gin.Engine { + // Init gin handler + router := gin.Default() + + router.Use( + gin.Recovery(), + gin.Logger(), + limiter.Limit(cfg.Limiter.RPS, cfg.Limiter.Burst, cfg.Limiter.TTL), + corsMiddleware, + ) + + h.initAPI(router) + + return router +} + +func (h *Handler) initAPI(router *gin.Engine) { + handlerV1 := v1.NewHandler(h.services) + api := router.Group("/api") + { + handlerV1.Init(api) + } +} diff --git a/internal/delivery/http/middleware.go b/internal/delivery/http/middleware.go new file mode 100644 index 0000000..1a65783 --- /dev/null +++ b/internal/delivery/http/middleware.go @@ -0,0 +1,20 @@ +package http + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +func corsMiddleware(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "*") + c.Header("Access-Control-Allow-Headers", "*") + c.Header("Content-Type", "application/json") + + if c.Request.Method != "OPTIONS" { + c.Next() + } else { + c.AbortWithStatus(http.StatusOK) + } +} diff --git a/internal/delivery/http/v1/handler.go b/internal/delivery/http/v1/handler.go new file mode 100644 index 0000000..4bf92f0 --- /dev/null +++ b/internal/delivery/http/v1/handler.go @@ -0,0 +1,25 @@ +package v1 + +import ( + "git.roboces.dev/catalin/cvvvvv/internal/service" + "github.com/gin-gonic/gin" +) + +type Handler struct { + services *service.Services +} + +func NewHandler(services *service.Services) *Handler { + return &Handler{ + services: services, + } +} + +func (h *Handler) Init(api *gin.RouterGroup) { + v1 := api.Group("/v1") + { + v1.GET("/usage", h.getUsage) + v1.GET("/ping", h.getPing) + v1.GET("/resume", h.getResume) + } +} diff --git a/internal/delivery/http/v1/middleware.go b/internal/delivery/http/v1/middleware.go new file mode 100644 index 0000000..8a376d4 --- /dev/null +++ b/internal/delivery/http/v1/middleware.go @@ -0,0 +1,30 @@ +package v1 + +import ( + "errors" + + "github.com/gin-gonic/gin" +) + +func containsValue(m map[string]string, value string) (string, error) { + for _, v := range m { + if v == value { + return v, nil + } + } + return value, errors.New("value is not in the provided map") +} + +func (h *Handler) VerifyAuthentication(ctx *gin.Context) (string, error) { + header := ctx.GetHeader("X-API-KEY") + if header != "" { + return containsValue(h.services.Config.Auth.ApiKeys, header) + } + + key := ctx.Query("api_key") + if key != "" { + return containsValue(h.services.Config.Auth.ApiKeys, key) + } + + return "", errors.New("Missing API key") +} diff --git a/internal/delivery/http/v1/ping.go b/internal/delivery/http/v1/ping.go new file mode 100644 index 0000000..e79848a --- /dev/null +++ b/internal/delivery/http/v1/ping.go @@ -0,0 +1,12 @@ +package v1 + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +func (h *Handler) getPing(c *gin.Context) { + c.Writer.Header().Set("Content-Type", "text/plain") + c.String(http.StatusOK, "pong") +} diff --git a/internal/delivery/http/v1/response.go b/internal/delivery/http/v1/response.go new file mode 100644 index 0000000..12cf8f7 --- /dev/null +++ b/internal/delivery/http/v1/response.go @@ -0,0 +1,19 @@ +package v1 + +import ( + "git.roboces.dev/catalin/cvvvvv/pkg/logger" + "github.com/gin-gonic/gin" +) + +type dataResponse struct { + Data interface{} `json:"data"` +} + +type response struct { + Message string `json:"message"` +} + +func newResponse(c *gin.Context, statusCode int, message string) { + logger.Error(message) + c.AbortWithStatusJSON(statusCode, response{message}) +} diff --git a/internal/delivery/http/v1/resume.go b/internal/delivery/http/v1/resume.go new file mode 100644 index 0000000..8cca6f1 --- /dev/null +++ b/internal/delivery/http/v1/resume.go @@ -0,0 +1,24 @@ +package v1 + +import ( + "net/http" + + "git.roboces.dev/catalin/cvvvvv/internal/domain" + "github.com/gin-gonic/gin" +) + +type ResumeResponse struct { + Resume domain.Resume `json:"resume"` +} + +func (h *Handler) getResume(ctx *gin.Context) { + + _, err := h.VerifyAuthentication(ctx) + if err != nil { + newResponse(ctx, http.StatusUnauthorized, "Api key not provided or invalid") + return + } + ctx.JSON(http.StatusOK, ResumeResponse{ + Resume: h.services.Resumes.Get(), + }) +} diff --git a/internal/delivery/http/v1/usage.go b/internal/delivery/http/v1/usage.go new file mode 100644 index 0000000..e55e6c8 --- /dev/null +++ b/internal/delivery/http/v1/usage.go @@ -0,0 +1,25 @@ +package v1 + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" +) + +func (h *Handler) getUsage(c *gin.Context) { + c.Writer.Header().Set("Content-Type", "text/plain") + message := fmt.Sprintf(` +Hello! If you're seeing this message it means you are authorized to view my CV. +You should have been provided an api key in order to query the different endpoints, +which are not public. You can put the key into a X-API-KEY header or use it +as a query param. Examples: + + curl %s/api/v1/usage -H "X-API-KEY: verysecret" + + curl %s/api/v1/usage?api_key=verysecret + +`, c.Request.Host, c.Request.Host) + + c.String(http.StatusOK, message) +} diff --git a/internal/domain/resume.go b/internal/domain/resume.go new file mode 100644 index 0000000..3909fcc --- /dev/null +++ b/internal/domain/resume.go @@ -0,0 +1,316 @@ +package domain + +import "github.com/atombender/go-jsonschema/pkg/types" + +// e.g. 2014-06-29 +type Iso8601 string + +type Resume struct { + // link to the version of the schema that can validate the resume + Schema *string `json:"$schema,omitempty" yaml:"$schema,omitempty" mapstructure:"$schema,omitempty"` + + // Specify any awards you have received throughout your professional career + Awards []ResumeAwardsElem `json:"awards,omitempty" yaml:"awards,omitempty" mapstructure:"awards,omitempty"` + + // Basics corresponds to the JSON schema field "basics". + Basics *ResumeBasics `json:"basics,omitempty" yaml:"basics,omitempty" mapstructure:"basics,omitempty"` + + // Specify any certificates you have received throughout your professional career + Certificates []ResumeCertificatesElem `json:"certificates,omitempty" yaml:"certificates,omitempty" mapstructure:"certificates,omitempty"` + + // Education corresponds to the JSON schema field "education". + Education []ResumeEducationElem `json:"education,omitempty" yaml:"education,omitempty" mapstructure:"education,omitempty"` + + // Interests corresponds to the JSON schema field "interests". + Interests []ResumeInterestsElem `json:"interests,omitempty" yaml:"interests,omitempty" mapstructure:"interests,omitempty"` + + // List any other languages you speak + Languages []ResumeLanguagesElem `json:"languages,omitempty" yaml:"languages,omitempty" mapstructure:"languages,omitempty"` + + // The schema version and any other tooling configuration lives here + Meta *ResumeMeta `json:"meta,omitempty" yaml:"meta,omitempty" mapstructure:"meta,omitempty"` + + // Specify career projects + Projects []ResumeProjectsElem `json:"projects,omitempty" yaml:"projects,omitempty" mapstructure:"projects,omitempty"` + + // Specify your publications through your career + Publications []ResumePublicationsElem `json:"publications,omitempty" yaml:"publications,omitempty" mapstructure:"publications,omitempty"` + + // List references you have received + References []ResumeReferencesElem `json:"references,omitempty" yaml:"references,omitempty" mapstructure:"references,omitempty"` + + // List out your professional skill-set + Skills []ResumeSkillsElem `json:"skills,omitempty" yaml:"skills,omitempty" mapstructure:"skills,omitempty"` + + // Volunteer corresponds to the JSON schema field "volunteer". + Volunteer []ResumeVolunteerElem `json:"volunteer,omitempty" yaml:"volunteer,omitempty" mapstructure:"volunteer,omitempty"` + + // Work corresponds to the JSON schema field "work". + Work []ResumeWorkElem `json:"work,omitempty" yaml:"work,omitempty" mapstructure:"work,omitempty"` +} + +type ResumeAwardsElem struct { + // e.g. Time Magazine + Awarder *string `json:"awarder,omitempty" yaml:"awarder,omitempty" mapstructure:"awarder,omitempty"` + + // Date corresponds to the JSON schema field "date". + Date *Iso8601 `json:"date,omitempty" yaml:"date,omitempty" mapstructure:"date,omitempty"` + + // e.g. Received for my work with Quantum Physics + Summary *string `json:"summary,omitempty" yaml:"summary,omitempty" mapstructure:"summary,omitempty"` + + // e.g. One of the 100 greatest minds of the century + Title *string `json:"title,omitempty" yaml:"title,omitempty" mapstructure:"title,omitempty"` +} + +type ResumeBasics struct { + // e.g. thomas@gmail.com + Email *string `json:"email,omitempty" yaml:"email,omitempty" mapstructure:"email,omitempty"` + + // URL (as per RFC 3986) to a image in JPEG or PNG format + Image *string `json:"image,omitempty" yaml:"image,omitempty" mapstructure:"image,omitempty"` + + // e.g. Web Developer + Label *string `json:"label,omitempty" yaml:"label,omitempty" mapstructure:"label,omitempty"` + + // Location corresponds to the JSON schema field "location". + Location *ResumeBasicsLocation `json:"location,omitempty" yaml:"location,omitempty" mapstructure:"location,omitempty"` + + // Name corresponds to the JSON schema field "name". + Name *string `json:"name,omitempty" yaml:"name,omitempty" mapstructure:"name,omitempty"` + + // Phone numbers are stored as strings so use any format you like, e.g. + // 712-117-2923 + Phone *string `json:"phone,omitempty" yaml:"phone,omitempty" mapstructure:"phone,omitempty"` + + // Specify any number of social networks that you participate in + Profiles []ResumeBasicsProfilesElem `json:"profiles,omitempty" yaml:"profiles,omitempty" mapstructure:"profiles,omitempty"` + + // Write a short 2-3 sentence biography about yourself + Summary *string `json:"summary,omitempty" yaml:"summary,omitempty" mapstructure:"summary,omitempty"` + + // URL (as per RFC 3986) to your website, e.g. personal homepage + Url *string `json:"url,omitempty" yaml:"url,omitempty" mapstructure:"url,omitempty"` +} + +type ResumeBasicsLocation struct { + // To add multiple address lines, use + // . For example, 1234 Glücklichkeit Straße + // Hinterhaus 5. Etage li. + Address *string `json:"address,omitempty" yaml:"address,omitempty" mapstructure:"address,omitempty"` + + // City corresponds to the JSON schema field "city". + City *string `json:"city,omitempty" yaml:"city,omitempty" mapstructure:"city,omitempty"` + + // code as per ISO-3166-1 ALPHA-2, e.g. US, AU, IN + CountryCode *string `json:"countryCode,omitempty" yaml:"countryCode,omitempty" mapstructure:"countryCode,omitempty"` + + // PostalCode corresponds to the JSON schema field "postalCode". + PostalCode *string `json:"postalCode,omitempty" yaml:"postalCode,omitempty" mapstructure:"postalCode,omitempty"` + + // The general region where you live. Can be a US state, or a province, for + // instance. + Region *string `json:"region,omitempty" yaml:"region,omitempty" mapstructure:"region,omitempty"` +} + +type ResumeBasicsProfilesElem struct { + // e.g. Facebook or Twitter + Network *string `json:"network,omitempty" yaml:"network,omitempty" mapstructure:"network,omitempty"` + + // e.g. http://twitter.example.com/neutralthoughts + Url *string `json:"url,omitempty" yaml:"url,omitempty" mapstructure:"url,omitempty"` + + // e.g. neutralthoughts + Username *string `json:"username,omitempty" yaml:"username,omitempty" mapstructure:"username,omitempty"` +} + +type ResumeCertificatesElem struct { + // e.g. 1989-06-12 + Date *types.SerializableDate `json:"date,omitempty" yaml:"date,omitempty" mapstructure:"date,omitempty"` + + // e.g. CNCF + Issuer *string `json:"issuer,omitempty" yaml:"issuer,omitempty" mapstructure:"issuer,omitempty"` + + // e.g. Certified Kubernetes Administrator + Name *string `json:"name,omitempty" yaml:"name,omitempty" mapstructure:"name,omitempty"` + + // e.g. http://example.com + Url *string `json:"url,omitempty" yaml:"url,omitempty" mapstructure:"url,omitempty"` +} + +type ResumeEducationElem struct { + // e.g. Arts + Area *string `json:"area,omitempty" yaml:"area,omitempty" mapstructure:"area,omitempty"` + + // List notable courses/subjects + Courses []string `json:"courses,omitempty" yaml:"courses,omitempty" mapstructure:"courses,omitempty"` + + // EndDate corresponds to the JSON schema field "endDate". + EndDate *Iso8601 `json:"endDate,omitempty" yaml:"endDate,omitempty" mapstructure:"endDate,omitempty"` + + // e.g. Massachusetts Institute of Technology + Institution *string `json:"institution,omitempty" yaml:"institution,omitempty" mapstructure:"institution,omitempty"` + + // grade point average, e.g. 3.67/4.0 + Score *string `json:"score,omitempty" yaml:"score,omitempty" mapstructure:"score,omitempty"` + + // StartDate corresponds to the JSON schema field "startDate". + StartDate *Iso8601 `json:"startDate,omitempty" yaml:"startDate,omitempty" mapstructure:"startDate,omitempty"` + + // e.g. Bachelor + StudyType *string `json:"studyType,omitempty" yaml:"studyType,omitempty" mapstructure:"studyType,omitempty"` + + // e.g. http://facebook.example.com + Url *string `json:"url,omitempty" yaml:"url,omitempty" mapstructure:"url,omitempty"` +} + +type ResumeInterestsElem struct { + // Keywords corresponds to the JSON schema field "keywords". + Keywords []string `json:"keywords,omitempty" yaml:"keywords,omitempty" mapstructure:"keywords,omitempty"` + + // e.g. Philosophy + Name *string `json:"name,omitempty" yaml:"name,omitempty" mapstructure:"name,omitempty"` +} + +type ResumeLanguagesElem struct { + // e.g. Fluent, Beginner + Fluency *string `json:"fluency,omitempty" yaml:"fluency,omitempty" mapstructure:"fluency,omitempty"` + + // e.g. English, Spanish + Language *string `json:"language,omitempty" yaml:"language,omitempty" mapstructure:"language,omitempty"` +} + +// The schema version and any other tooling configuration lives here +type ResumeMeta struct { + // URL (as per RFC 3986) to latest version of this document + Canonical *string `json:"canonical,omitempty" yaml:"canonical,omitempty" mapstructure:"canonical,omitempty"` + + // Using ISO 8601 with YYYY-MM-DDThh:mm:ss + LastModified *string `json:"lastModified,omitempty" yaml:"lastModified,omitempty" mapstructure:"lastModified,omitempty"` + + // A version field which follows semver - e.g. v1.0.0 + Version *string `json:"version,omitempty" yaml:"version,omitempty" mapstructure:"version,omitempty"` +} + +type ResumeProjectsElem struct { + // Short summary of project. e.g. Collated works of 2017. + Description *string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"` + + // EndDate corresponds to the JSON schema field "endDate". + EndDate *Iso8601 `json:"endDate,omitempty" yaml:"endDate,omitempty" mapstructure:"endDate,omitempty"` + + // Specify the relevant company/domain affiliations e.g. 'greenpeace', + // 'corporationXYZ' + Entity *string `json:"domain,omitempty" yaml:"domain,omitempty" mapstructure:"domain,omitempty"` + + // Specify multiple features + Highlights []string `json:"highlights,omitempty" yaml:"highlights,omitempty" mapstructure:"highlights,omitempty"` + + // Specify special elements involved + Keywords []string `json:"keywords,omitempty" yaml:"keywords,omitempty" mapstructure:"keywords,omitempty"` + + // e.g. The World Wide Web + Name *string `json:"name,omitempty" yaml:"name,omitempty" mapstructure:"name,omitempty"` + + // Specify your role on this project or in company + Roles []string `json:"roles,omitempty" yaml:"roles,omitempty" mapstructure:"roles,omitempty"` + + // StartDate corresponds to the JSON schema field "startDate". + StartDate *Iso8601 `json:"startDate,omitempty" yaml:"startDate,omitempty" mapstructure:"startDate,omitempty"` + + // e.g. 'volunteering', 'presentation', 'talk', 'application', 'conference' + Type *string `json:"type,omitempty" yaml:"type,omitempty" mapstructure:"type,omitempty"` + + // e.g. http://www.computer.org/csdl/mags/co/1996/10/rx069-abs.html + Url *string `json:"url,omitempty" yaml:"url,omitempty" mapstructure:"url,omitempty"` +} + +type ResumePublicationsElem struct { + // e.g. The World Wide Web + Name *string `json:"name,omitempty" yaml:"name,omitempty" mapstructure:"name,omitempty"` + + // e.g. IEEE, Computer Magazine + Publisher *string `json:"publisher,omitempty" yaml:"publisher,omitempty" mapstructure:"publisher,omitempty"` + + // ReleaseDate corresponds to the JSON schema field "releaseDate". + ReleaseDate *Iso8601 `json:"releaseDate,omitempty" yaml:"releaseDate,omitempty" mapstructure:"releaseDate,omitempty"` + + // Short summary of publication. e.g. Discussion of the World Wide Web, HTTP, + // HTML. + Summary *string `json:"summary,omitempty" yaml:"summary,omitempty" mapstructure:"summary,omitempty"` + + // e.g. http://www.computer.org.example.com/csdl/mags/co/1996/10/rx069-abs.html + Url *string `json:"url,omitempty" yaml:"url,omitempty" mapstructure:"url,omitempty"` +} + +type ResumeReferencesElem struct { + // e.g. Timothy Cook + Name *string `json:"name,omitempty" yaml:"name,omitempty" mapstructure:"name,omitempty"` + + // e.g. Joe blogs was a great employee, who turned up to work at least once a + // week. He exceeded my expectations when it came to doing nothing. + Reference *string `json:"reference,omitempty" yaml:"reference,omitempty" mapstructure:"reference,omitempty"` +} + +type ResumeSkillsElem struct { + // List some keywords pertaining to this skill + Keywords []string `json:"keywords,omitempty" yaml:"keywords,omitempty" mapstructure:"keywords,omitempty"` + + // e.g. Master + Level *string `json:"level,omitempty" yaml:"level,omitempty" mapstructure:"level,omitempty"` + + // e.g. Web Development + Name *string `json:"name,omitempty" yaml:"name,omitempty" mapstructure:"name,omitempty"` +} + +type ResumeVolunteerElem struct { + // EndDate corresponds to the JSON schema field "endDate". + EndDate *Iso8601 `json:"endDate,omitempty" yaml:"endDate,omitempty" mapstructure:"endDate,omitempty"` + + // Specify accomplishments and achievements + Highlights []string `json:"highlights,omitempty" yaml:"highlights,omitempty" mapstructure:"highlights,omitempty"` + + // e.g. Facebook + Organization *string `json:"organization,omitempty" yaml:"organization,omitempty" mapstructure:"organization,omitempty"` + + // e.g. Software Engineer + Position *string `json:"position,omitempty" yaml:"position,omitempty" mapstructure:"position,omitempty"` + + // StartDate corresponds to the JSON schema field "startDate". + StartDate *Iso8601 `json:"startDate,omitempty" yaml:"startDate,omitempty" mapstructure:"startDate,omitempty"` + + // Give an overview of your responsibilities at the company + Summary *string `json:"summary,omitempty" yaml:"summary,omitempty" mapstructure:"summary,omitempty"` + + // e.g. http://facebook.example.com + Url *string `json:"url,omitempty" yaml:"url,omitempty" mapstructure:"url,omitempty"` +} + +type ResumeWorkElem struct { + // e.g. Social Media Company + Description *string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"` + + // EndDate corresponds to the JSON schema field "endDate". + EndDate *Iso8601 `json:"endDate,omitempty" yaml:"endDate,omitempty" mapstructure:"endDate,omitempty"` + + // Specify multiple accomplishments + Highlights []string `json:"highlights,omitempty" yaml:"highlights,omitempty" mapstructure:"highlights,omitempty"` + + // e.g. Menlo Park, CA + Location *string `json:"location,omitempty" yaml:"location,omitempty" mapstructure:"location,omitempty"` + + // e.g. Facebook + Name *string `json:"name,omitempty" yaml:"name,omitempty" mapstructure:"name,omitempty"` + + // e.g. Software Engineer + Position *string `json:"position,omitempty" yaml:"position,omitempty" mapstructure:"position,omitempty"` + + // StartDate corresponds to the JSON schema field "startDate". + StartDate *Iso8601 `json:"startDate,omitempty" yaml:"startDate,omitempty" mapstructure:"startDate,omitempty"` + + // Give an overview of your responsibilities at the company + Summary *string `json:"summary,omitempty" yaml:"summary,omitempty" mapstructure:"summary,omitempty"` + + // e.g. http://facebook.example.com + Url *string `json:"url,omitempty" yaml:"url,omitempty" mapstructure:"url,omitempty"` +} diff --git a/internal/repo/repos.go b/internal/repo/repos.go new file mode 100644 index 0000000..a24e441 --- /dev/null +++ b/internal/repo/repos.go @@ -0,0 +1,19 @@ +package repo + +import ( + "git.roboces.dev/catalin/cvvvvv/internal/domain" +) + +type Resumes interface { + Get() domain.Resume +} + +type Repos struct { + Resumes +} + +func NewRepos(db domain.Resume) *Repos { + return &Repos{ + Resumes: NewResumesRepo(db), + } +} diff --git a/internal/repo/resumes.go b/internal/repo/resumes.go new file mode 100644 index 0000000..c4aa4f8 --- /dev/null +++ b/internal/repo/resumes.go @@ -0,0 +1,19 @@ +package repo + +import ( + "git.roboces.dev/catalin/cvvvvv/internal/domain" +) + +type ResumesRepo struct { + db domain.Resume +} + +func NewResumesRepo(db domain.Resume) *ResumesRepo { + return &ResumesRepo{ + db: db, + } +} + +func (r *ResumesRepo) Get() domain.Resume { + return r.db +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..8643822 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,32 @@ +package server + +import ( + "context" + "net/http" + + "git.roboces.dev/catalin/cvvvvv/internal/config" +) + +type Server struct { + httpServer *http.Server +} + +func NewServer(cfg *config.Config, handler http.Handler) *Server { + return &Server{ + httpServer: &http.Server{ + Addr: ":" + cfg.HTTP.Port, + Handler: handler, + ReadTimeout: cfg.HTTP.ReadTimeout, + WriteTimeout: cfg.HTTP.WriteTimeout, + MaxHeaderBytes: cfg.HTTP.MaxHeaderMegabytes << 20, + }, + } +} + +func (s *Server) Run() error { + return s.httpServer.ListenAndServe() +} + +func (s *Server) Stop(ctx context.Context) error { + return s.httpServer.Shutdown(ctx) +} diff --git a/internal/service/resume.go b/internal/service/resume.go new file mode 100644 index 0000000..c66e57a --- /dev/null +++ b/internal/service/resume.go @@ -0,0 +1,20 @@ +package service + +import ( + "git.roboces.dev/catalin/cvvvvv/internal/domain" + "git.roboces.dev/catalin/cvvvvv/internal/repo" +) + +type ResumesService struct { + repo repo.Resumes +} + +func NewResumesService(repo repo.Resumes) *ResumesService { + return &ResumesService{ + repo: repo, + } +} + +func (r *ResumesService) Get() domain.Resume { + return r.repo.Get() +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..84362d0 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,27 @@ +package service + +import ( + "git.roboces.dev/catalin/cvvvvv/internal/config" + "git.roboces.dev/catalin/cvvvvv/internal/domain" + "git.roboces.dev/catalin/cvvvvv/internal/repo" +) + +type Services struct { + Resumes Resumes + Config *config.Config +} + +type Deps struct { + Repos *repo.Repos + Config *config.Config +} + +func NewServices(deps Deps) *Services { + resumeService := NewResumesService(deps.Repos.Resumes) + + return &Services{Resumes: resumeService, Config: deps.Config} +} + +type Resumes interface { + Get() domain.Resume +} diff --git a/pkg/limiter/limiter.go b/pkg/limiter/limiter.go new file mode 100644 index 0000000..2ee44c2 --- /dev/null +++ b/pkg/limiter/limiter.go @@ -0,0 +1,100 @@ +package limiter + +import ( + "net" + "net/http" + "sync" + "time" + + "git.roboces.dev/catalin/cvvvvv/pkg/logger" + "github.com/gin-gonic/gin" + "golang.org/x/time/rate" +) + +// visitor holds limiter and lastSeen for specific user. +type visitor struct { + limiter *rate.Limiter + lastSeen time.Time +} + +// rateLimiter used to rate limit an incoming requests. +type rateLimiter struct { + sync.RWMutex + + visitors map[string]*visitor + limit rate.Limit + burst int + ttl time.Duration +} + +// newRateLimiter creates an instance of the rateLimiter. +func newRateLimiter(rps, burst int, ttl time.Duration) *rateLimiter { + return &rateLimiter{ + visitors: make(map[string]*visitor), + limit: rate.Limit(rps), + burst: burst, + ttl: ttl, + } +} + +// getVisitor returns limiter for the specific visitor by its IP, +// looking up within the visitors map. +func (l *rateLimiter) getVisitor(ip string) *rate.Limiter { + l.RLock() + v, exists := l.visitors[ip] + l.RUnlock() + + if !exists { + limiter := rate.NewLimiter(l.limit, l.burst) + l.Lock() + l.visitors[ip] = &visitor{limiter, time.Now()} + l.Unlock() + + return limiter + } + + v.lastSeen = time.Now() + + return v.limiter +} + +// cleanupVisitors removes old entries from the visitors map. +func (l *rateLimiter) cleanupVisitors() { + for { + time.Sleep(time.Minute) + + l.Lock() + for ip, v := range l.visitors { + if time.Since(v.lastSeen) > l.ttl { + delete(l.visitors, ip) + } + } + l.Unlock() + } +} + +// Limit creates a new rate limiter middleware handler. +func Limit(rps int, burst int, ttl time.Duration) gin.HandlerFunc { + l := newRateLimiter(rps, burst, ttl) + + // run a background worker to clean up old entries + go l.cleanupVisitors() + + return func(c *gin.Context) { + ip, _, err := net.SplitHostPort(c.Request.RemoteAddr) + if err != nil { + logger.Error(err) + c.AbortWithStatus(http.StatusInternalServerError) + + return + } + + if !l.getVisitor(ip).Allow() { + c.AbortWithStatus(http.StatusTooManyRequests) + + return + } + + c.Next() + } +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..1c6c4a0 --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,8 @@ +package logger + +type Logger interface { + Debug(msg string, params map[string]interface{}) + Info(msg string, params map[string]interface{}) + Warn(msg string, params map[string]interface{}) + Error(msg string, params map[string]interface{}) +} diff --git a/pkg/logger/logrus.go b/pkg/logger/logrus.go new file mode 100644 index 0000000..a1e1f0d --- /dev/null +++ b/pkg/logger/logrus.go @@ -0,0 +1,35 @@ +package logger + +import "github.com/sirupsen/logrus" + +func Debug(msg ...interface{}) { + logrus.Debug(msg...) +} + +func Debugf(format string, args ...interface{}) { + logrus.Debugf(format, args...) +} + +func Info(msg ...interface{}) { + logrus.Info(msg...) +} + +func Infof(format string, args ...interface{}) { + logrus.Infof(format, args...) +} + +func Warn(msg ...interface{}) { + logrus.Warn(msg...) +} + +func Warnf(format string, args ...interface{}) { + logrus.Warnf(format, args...) +} + +func Error(msg ...interface{}) { + logrus.Error(msg...) +} + +func Errorf(format string, args ...interface{}) { + logrus.Errorf(format, args...) +}