wip: add api auth and /resume endpoint

This commit is contained in:
cătălin 2023-12-04 22:13:37 +01:00
commit aaec8ec08d
Signed by: catalin
GPG key ID: 0178DF42F43E5FD2
23 changed files with 1244 additions and 271 deletions

287
.gitignore vendored
View file

@ -1,277 +1,22 @@
# ---> Python # Ignore everything
# Byte-compiled / optimized / DLL files *
__pycache__/
*.py[cod]
*$py.class
# C extensions # But don't ignore these files...
*.so !/.gitignore
# Distribution / packaging !*.go
.Python !go.sum
build/ !go.mod
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller !README.md
# Usually these files are written by a python script from a template !LICENSE
# before PyInstaller builds the exe, so as to inject date/other infos into it. !apischema/openapi.yaml
*.manifest !apischema/resumeschema.json
*.spec !configs/config.yml
!configs/example.db.json
# Installer logs # !Makefile
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports # ...even if they are in subdirectories
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~

View file

@ -1,2 +1,3 @@
# cvvvv # cvvvv
WIP

260
apischema/openapi.yaml Normal file
View file

@ -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

9
cmd/app/main.go Normal file
View file

@ -0,0 +1,9 @@
package main
import "git.roboces.dev/catalin/cvvvvv/internal/app"
const configsDir = "configs"
func main() {
app.Run(configsDir)
}

110
configs/example.db.json Normal file
View file

@ -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/"
}]
}

74
internal/app/app.go Normal file
View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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")
}

View file

@ -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")
}

View file

@ -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})
}

View file

@ -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(),
})
}

View file

@ -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)
}

316
internal/domain/resume.go Normal file
View file

@ -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"`
}

19
internal/repo/repos.go Normal file
View file

@ -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),
}
}

19
internal/repo/resumes.go Normal file
View file

@ -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
}

32
internal/server/server.go Normal file
View file

@ -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)
}

View file

@ -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()
}

View file

@ -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
}

100
pkg/limiter/limiter.go Normal file
View file

@ -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()
}
}

8
pkg/logger/logger.go Normal file
View file

@ -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{})
}

35
pkg/logger/logrus.go Normal file
View file

@ -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...)
}