refactor: restructure project
All checks were successful
ci/woodpecker/push/lint Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful

This commit is contained in:
Tom Neuber 2024-11-26 13:35:54 +01:00
parent 1bde2041e1
commit 8215e1f13a
Signed by: tom
GPG key ID: F17EFE4272D89FF6
21 changed files with 850 additions and 269 deletions

358
.golangci.yml Normal file
View file

@ -0,0 +1,358 @@
# This code is licensed under the terms of the MIT license https://opensource.org/license/mit
# Copyright (c) 2024 Marat Reymers
## Golden config for golangci-lint v1.62.0
#
# This is the best config for golangci-lint based on my experience and opinion.
# It is very strict, but not extremely strict.
# Feel free to adapt and change it for your needs.
run:
# Timeout for analysis, e.g. 30s, 5m.
# Default: 1m
timeout: 3m
# This file contains only configs which differ from defaults.
# All possible options can be found here https://github.com/golangci/golangci-lint/blob/master/.golangci.reference.yml
linters-settings:
cyclop:
# The maximal code complexity to report.
# Default: 10
max-complexity: 30
# The maximal average package complexity.
# If it's higher than 0.0 (float) the check is enabled
# Default: 0.0
package-average: 10.0
errcheck:
# Report about not checking of errors in type assertions: `a := b.(MyStruct)`.
# Such cases aren't reported by default.
# Default: false
check-type-assertions: true
exhaustive:
# Program elements to check for exhaustiveness.
# Default: [ switch ]
check:
- switch
- map
exhaustruct:
# List of regular expressions to exclude struct packages and their names from checks.
# Regular expressions must match complete canonical struct package/name/structname.
# Default: []
exclude:
# std libs
- "^net/http.Client$"
- "^net/http.Cookie$"
- "^net/http.Request$"
- "^net/http.Response$"
- "^net/http.Server$"
- "^net/http.Transport$"
- "^net/url.URL$"
- "^os/exec.Cmd$"
- "^reflect.StructField$"
# public libs
- "^github.com/Shopify/sarama.Config$"
- "^github.com/Shopify/sarama.ProducerMessage$"
- "^github.com/mitchellh/mapstructure.DecoderConfig$"
- "^github.com/prometheus/client_golang/.+Opts$"
- "^github.com/spf13/cobra.Command$"
- "^github.com/spf13/cobra.CompletionOptions$"
- "^github.com/stretchr/testify/mock.Mock$"
- "^github.com/testcontainers/testcontainers-go.+Request$"
- "^github.com/testcontainers/testcontainers-go.FromDockerfile$"
- "^golang.org/x/tools/go/analysis.Analyzer$"
- "^google.golang.org/protobuf/.+Options$"
- "^gopkg.in/yaml.v3.Node$"
funlen:
# Checks the number of lines in a function.
# If lower than 0, disable the check.
# Default: 60
lines: 100
# Checks the number of statements in a function.
# If lower than 0, disable the check.
# Default: 40
statements: 50
# Ignore comments when counting lines.
# Default false
ignore-comments: true
gochecksumtype:
# Presence of `default` case in switch statements satisfies exhaustiveness, if all members are not listed.
# Default: true
default-signifies-exhaustive: false
gocognit:
# Minimal code complexity to report.
# Default: 30 (but we recommend 10-20)
min-complexity: 20
gocritic:
# Settings passed to gocritic.
# The settings key is the name of a supported gocritic checker.
# The list of supported checkers can be find in https://go-critic.github.io/overview.
settings:
captLocal:
# Whether to restrict checker to params only.
# Default: true
paramsOnly: false
underef:
# Whether to skip (*x).method() calls where x is a pointer receiver.
# Default: true
skipRecvDeref: false
gomodguard:
blocked:
# List of blocked modules.
# Default: []
modules:
- github.com/golang/protobuf:
recommendations:
- google.golang.org/protobuf
reason: "see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules"
- github.com/satori/go.uuid:
recommendations:
- github.com/google/uuid
reason: "satori's package is not maintained"
- github.com/gofrs/uuid:
recommendations:
- github.com/gofrs/uuid/v5
reason: "gofrs' package was not go module before v5"
govet:
# Enable all analyzers.
# Default: false
enable-all: true
# Disable analyzers by name.
# Run `go tool vet help` to see all analyzers.
# Default: []
disable:
- fieldalignment # too strict
# Settings per analyzer.
settings:
shadow:
# Whether to be strict about shadowing; can be noisy.
# Default: false
strict: true
inamedparam:
# Skips check for interface methods with only a single parameter.
# Default: false
skip-single-param: true
mnd:
# List of function patterns to exclude from analysis.
# Values always ignored: `time.Date`,
# `strconv.FormatInt`, `strconv.FormatUint`, `strconv.FormatFloat`,
# `strconv.ParseInt`, `strconv.ParseUint`, `strconv.ParseFloat`.
# Default: []
ignored-functions:
- args.Error
- flag.Arg
- flag.Duration.*
- flag.Float.*
- flag.Int.*
- flag.Uint.*
- os.Chmod
- os.Mkdir.*
- os.OpenFile
- os.WriteFile
- prometheus.ExponentialBuckets.*
- prometheus.LinearBuckets
nakedret:
# Make an issue if func has more lines of code than this setting, and it has naked returns.
# Default: 30
max-func-lines: 0
nolintlint:
# Exclude following linters from requiring an explanation.
# Default: []
allow-no-explanation: [ funlen, gocognit, lll ]
# Enable to require an explanation of nonzero length after each nolint directive.
# Default: false
require-explanation: true
# Enable to require nolint directives to mention the specific linter being suppressed.
# Default: false
require-specific: true
perfsprint:
# Optimizes into strings concatenation.
# Default: true
strconcat: false
rowserrcheck:
# database/sql is always checked
# Default: []
packages:
- github.com/jmoiron/sqlx
sloglint:
# Enforce not using global loggers.
# Values:
# - "": disabled
# - "all": report all global loggers
# - "default": report only the default slog logger
# https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global
# Default: ""
no-global: "all"
# Enforce using methods that accept a context.
# Values:
# - "": disabled
# - "all": report all contextless calls
# - "scope": report only if a context exists in the scope of the outermost function
# https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only
# Default: ""
context: "scope"
tenv:
# The option `all` will run against whole test files (`_test.go`) regardless of method/function signatures.
# Otherwise, only methods that take `*testing.T`, `*testing.B`, and `testing.TB` as arguments are checked.
# Default: false
all: true
linters:
disable-all: true
enable:
## enabled by default
- errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases
- gosimple # specializes in simplifying a code
- govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string
- ineffassign # detects when assignments to existing variables are not used
- staticcheck # is a go vet on steroids, applying a ton of static analysis checks
- typecheck # like the front-end of a Go compiler, parses and type-checks Go code
- unused # checks for unused constants, variables, functions and types
## disabled by default
- asasalint # checks for pass []any as any in variadic func(...any)
- asciicheck # checks that your code does not contain non-ASCII identifiers
- bidichk # checks for dangerous unicode character sequences
- bodyclose # checks whether HTTP response body is closed successfully
- canonicalheader # checks whether net/http.Header uses canonical header
- copyloopvar # detects places where loop variables are copied (Go 1.22+)
- cyclop # checks function and package cyclomatic complexity
- dupl # tool for code clone detection
- durationcheck # checks for two durations multiplied together
- errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error
- errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13
- exhaustive # checks exhaustiveness of enum switch statements
- fatcontext # detects nested contexts in loops
- forbidigo # forbids identifiers
- funlen # tool for detection of long functions
- gocheckcompilerdirectives # validates go compiler directive comments (//go:)
- gochecknoglobals # checks that no global variables exist
- gochecknoinits # checks that no init functions are present in Go code
- gochecksumtype # checks exhaustiveness on Go "sum types"
- gocognit # computes and checks the cognitive complexity of functions
- goconst # finds repeated strings that could be replaced by a constant
- gocritic # provides diagnostics that check for bugs, performance and style issues
- gocyclo # computes and checks the cyclomatic complexity of functions
- godot # checks if comments end in a period
- goimports # in addition to fixing imports, goimports also formats your code in the same style as gofmt
- gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod
- gomodguard # allow and block lists linter for direct Go module dependencies. This is different from depguard where there are different block types for example version constraints and module recommendations
- goprintffuncname # checks that printf-like functions are named with f at the end
- gosec # inspects source code for security problems
- iface # checks the incorrect use of interfaces, helping developers avoid interface pollution
- intrange # finds places where for loops could make use of an integer range
- lll # reports long lines
- loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap)
- makezero # finds slice declarations with non-zero initial length
- mirror # reports wrong mirror patterns of bytes/strings usage
- mnd # detects magic numbers
- musttag # enforces field tags in (un)marshaled structs
- nakedret # finds naked returns in functions greater than a specified function length
- nestif # reports deeply nested if statements
- nilerr # finds the code that returns nil even if it checks that the error is not nil
- nilnil # checks that there is no simultaneous return of nil error and an invalid value
- noctx # finds sending http request without context.Context
- nolintlint # reports ill-formed or insufficient nolint directives
- nonamedreturns # reports all named returns
- nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL
- perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative
- predeclared # finds code that shadows one of Go's predeclared identifiers
- promlinter # checks Prometheus metrics naming via promlint
- protogetter # reports direct reads from proto message fields when getters should be used
- reassign # checks that package variables are not reassigned
- recvcheck # checks for receiver type consistency
- revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint
- rowserrcheck # checks whether Err of rows is checked successfully
- sloglint # ensure consistent code style when using log/slog
- spancheck # checks for mistakes with OpenTelemetry/Census spans
- sqlclosecheck # checks that sql.Rows and sql.Stmt are closed
- stylecheck # is a replacement for golint
- tenv # detects using os.Setenv instead of t.Setenv since Go1.17
- testableexamples # checks if examples are testable (have an expected output)
- testifylint # checks usage of github.com/stretchr/testify
- testpackage # makes you use a separate _test package
- tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes
- unconvert # removes unnecessary type conversions
- unparam # reports unused function parameters
- usestdlibvars # detects the possibility to use variables/constants from the Go standard library
- wastedassign # finds wasted assignment statements
- whitespace # detects leading and trailing whitespace
## you may want to enable
#- decorder # checks declaration order and count of types, constants, variables and functions
#- exhaustruct # [highly recommend to enable] checks if all structure fields are initialized
#- gci # controls golang package import order and makes it always deterministic
#- ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega
#- godox # detects FIXME, TODO and other comment keywords
#- goheader # checks is file header matches to pattern
#- inamedparam # [great idea, but too strict, need to ignore a lot of cases by default] reports interfaces with unnamed method parameters
#- interfacebloat # checks the number of methods inside an interface
#- ireturn # accept interfaces, return concrete types
#- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated
#- tagalign # checks that struct tags are well aligned
#- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope
#- wrapcheck # checks that errors returned from external packages are wrapped
#- zerologlint # detects the wrong usage of zerolog that a user forgets to dispatch zerolog.Event
## disabled
#- containedctx # detects struct contained context.Context field
#- contextcheck # [too many false positives] checks the function whether use a non-inherited context
#- depguard # [replaced by gomodguard] checks if package imports are in a list of acceptable packages
#- dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f())
#- dupword # [useless without config] checks for duplicate words in the source code
#- err113 # [too strict] checks the errors handling expressions
#- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted
#- exportloopref # [not necessary from Go 1.22] checks for pointers to enclosing loop variables
#- forcetypeassert # [replaced by errcheck] finds forced type assertions
#- gofmt # [replaced by goimports] checks whether code was gofmt-ed
#- gofumpt # [replaced by goimports, gofumports is not available yet] checks whether code was gofumpt-ed
#- gosmopolitan # reports certain i18n/l10n anti-patterns in your Go codebase
#- grouper # analyzes expression groups
#- importas # enforces consistent import aliases
#- maintidx # measures the maintainability index of each function
#- misspell # [useless] finds commonly misspelled English words in comments
#- nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity
#- paralleltest # [too many false positives] detects missing usage of t.Parallel() method in your Go test
#- tagliatelle # checks the struct tags
#- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers
#- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines
issues:
# Maximum count of issues with the same text.
# Set to 0 to disable.
# Default: 3
max-same-issues: 50
exclude-rules:
- source: "(noinspection|TODO)"
linters: [ godot ]
- source: "//noinspection"
linters: [ gocritic ]
- path: "_test\\.go"
linters:
- bodyclose
- dupl
- errcheck
- funlen
- goconst
- gosec
- noctx
- wrapcheck

View file

@ -38,5 +38,5 @@ steps:
exclude: main
event: push
depends_on:
- gofmt
- vulncheck
- lint
- test

View file

@ -55,3 +55,5 @@ steps:
event: push
depends_on:
- build
- lint
- test

20
.woodpecker/.lint.yaml Normal file
View file

@ -0,0 +1,20 @@
steps:
- name: gofmt
image: golang:1.23.3
commands:
- gofmt -l -s .
when:
- event: push
- name: vuln-check
image: golang:1.23.3
commands:
- go install golang.org/x/vuln/cmd/govulncheck@latest
- govulncheck ./...
when:
- event: push
- name: golangci-linter
image: golangci/golangci-lint:v1.62.0
commands:
- golangci-lint run ./...
when:
- event: push

View file

@ -1,7 +1,9 @@
steps:
- name: gofmt
- name: gotest
image: golang:1.23.3
commands:
- gofmt -l -s .
- go test ./...
when:
- event: push
depends_on:
- lint

View file

@ -1,8 +0,0 @@
steps:
- name: vuln-check
image: golang:1.23.3
commands:
- go install golang.org/x/vuln/cmd/govulncheck@latest
- govulncheck ./...
when:
- event: push

65
api/v1/handler.go Normal file
View file

@ -0,0 +1,65 @@
package apiv1
import (
"context"
"errors"
"net/http"
"git.ar21.de/yolokube/country-geo-locations/internal/cache"
"git.ar21.de/yolokube/country-geo-locations/internal/database"
"git.ar21.de/yolokube/country-geo-locations/pkg/geoloc"
"github.com/go-chi/chi/v5"
)
type LocationHandler struct {
cache *cache.Cache
db *database.Database
}
func NewLocationHandler(cache *cache.Cache, db *database.Database) *LocationHandler {
return &LocationHandler{
cache: cache,
db: db,
}
}
func (lh *LocationHandler) SearchIPHandlerFunc(w http.ResponseWriter, r *http.Request) {
ipinfo, ok := r.Context().Value(keyIPInfo).(*geoloc.IPInfo)
if !ok {
renderResponse(w, r, errRender(errors.New("could not get ipinfo object")))
return
}
renderResponse(w, r, newIPInfoResponse(ipinfo))
}
func (lh *LocationHandler) SearchIPHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var ipinfo *geoloc.IPInfo
if ipAddress := chi.URLParam(r, "ipAddress"); ipAddress != "" {
ipnetnum, err := geoloc.CalculateIPNum(ipAddress)
if err != nil {
renderResponse(w, r, errInvalidRequest(err))
return
}
newipnet, found := lh.cache.Get(ipnetnum)
if found {
ipnetnum = newipnet
}
ipinfo, err = lh.db.SearchIPNet(ipnetnum)
if err != nil {
renderResponse(w, r, errNotFound())
return
}
if !found {
lh.cache.Set(ipnetnum, ipinfo.IPNumFrom)
}
}
ctx := context.WithValue(r.Context(), keyIPInfo, ipinfo)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

39
api/v1/renderer.go Normal file
View file

@ -0,0 +1,39 @@
package apiv1
import (
"log"
"net/http"
"github.com/go-chi/render"
)
func errNotFound() render.Renderer {
return &errResponse{
HTTPStatusCode: http.StatusNotFound,
StatusText: "Resource not found",
}
}
func errInvalidRequest(err error) render.Renderer {
return &errResponse{
Err: err,
HTTPStatusCode: http.StatusBadRequest,
StatusText: "Invalid request",
ErrorText: err.Error(),
}
}
func errRender(err error) render.Renderer {
return &errResponse{
Err: err,
HTTPStatusCode: http.StatusUnprocessableEntity,
StatusText: "Error rendering response",
ErrorText: err.Error(),
}
}
func renderResponse(w http.ResponseWriter, r *http.Request, v render.Renderer) {
if err := render.Render(w, r, v); err != nil {
log.Fatal(err)
}
}

View file

@ -1,76 +1,28 @@
package api_v1
package apiv1
import (
"context"
"net/http"
"time"
"git.ar21.de/yolokube/country-geo-locations/pkg/geoloc"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
func ApiRouter() chi.Router {
func NewRouter(lh *LocationHandler) chi.Router {
r := chi.NewRouter()
r.Use(render.SetContentType(render.ContentTypeJSON))
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("pong"))
})
r.Route("/location", func(r chi.Router) {
r.Route("/{ipAddress}", func(r chi.Router) {
r.Use(searchIPCtx)
r.Get("/", searchIP)
r.Use(lh.SearchIPHandler)
r.Get("/", lh.SearchIPHandlerFunc)
})
})
return r
}
func searchIP(w http.ResponseWriter, r *http.Request) {
ipinfo := r.Context().Value(keyIPInfo).(*geoloc.IPInfo)
if err := render.Render(w, r, newIPInfoResponse(ipinfo)); err != nil {
render.Render(w, r, errRender(err))
return
}
}
func searchIPCtx(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var ipinfo *geoloc.IPInfo
var ipnetnum uint
var err error
if ipAddress := chi.URLParam(r, "ipAddress"); ipAddress != "" {
ipnetnum, err = geoloc.GetIPInfo(ipAddress)
if err != nil {
render.Render(w, r, errInvalidRequest(err))
return
}
newipnet, found := geoloc.GetCacheContent(ipnetnum)
if found {
ipnetnum = newipnet
}
ipinfo, err = geoloc.SearchIPNet(ipnetnum)
if err != nil {
render.Render(w, r, errNotFound)
return
}
if !found {
geoloc.SetCacheContent(ipnetnum, ipinfo.IPNumFrom, 2*time.Minute)
}
}
ctx := context.WithValue(r.Context(), keyIPInfo, ipinfo)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
type errResponse struct {
Err error `json:"-"`
HTTPStatusCode int `json:"-"`
@ -80,31 +32,11 @@ type errResponse struct {
ErrorText string `json:"error,omitempty"`
}
func (e *errResponse) Render(w http.ResponseWriter, r *http.Request) error {
func (e *errResponse) Render(_ http.ResponseWriter, r *http.Request) error {
render.Status(r, e.HTTPStatusCode)
return nil
}
func errInvalidRequest(err error) render.Renderer {
return &errResponse{
Err: err,
HTTPStatusCode: 400,
StatusText: "Invalid request",
ErrorText: err.Error(),
}
}
func errRender(err error) render.Renderer {
return &errResponse{
Err: err,
HTTPStatusCode: 422,
StatusText: "Error rendering response",
ErrorText: err.Error(),
}
}
var errNotFound = &errResponse{HTTPStatusCode: 404, StatusText: "Resource not found"}
type key int
const keyIPInfo key = iota
@ -113,7 +45,7 @@ type ipInfoResponse struct {
*geoloc.IPInfo
}
func (ir *ipInfoResponse) Render(w http.ResponseWriter, r *http.Request) error {
func (ir *ipInfoResponse) Render(_ http.ResponseWriter, _ *http.Request) error {
return nil
}

View file

@ -1,45 +1,64 @@
package cfg
import (
"fmt"
"errors"
"time"
"github.com/alecthomas/kong"
)
var ErrInvalidDataURL = errors.New("invalid data url, must not be blank")
type AppSettings struct {
ServerAddress string `name:"listen-address" env:"GEOIP_LISTEN_ADDRESS" help:"Address to use for the metrics server" default:"${default_address}"`
DataFile string `name:"data-file" env:"GEOIP_DATA_FILE" help:"path to data file" default:"${default_file_path}"`
DataURL string `name:"data-url" env:"GEOIP_DATA_URL" help:"url to data file"`
ServerAddress string
DataFile string
DataURL string
CacheTTL time.Duration
ReadHeaderTimeout time.Duration
}
func NewAppSettings() *AppSettings {
return &AppSettings{}
//nolint:lll // ignore line length
type CLI struct {
ServerAddress string `name:"listen-address" env:"GEOIP_LISTEN_ADDRESS" help:"Address to use for the metrics server" default:"${default_address}"`
DataFile string `name:"data-file" env:"GEOIP_DATA_FILE" help:"path to data file" default:"${default_file_path}"`
DataURL string `name:"data-url" env:"GEOIP_DATA_URL" help:"url to data file"`
CacheTTL string `name:"cache-ttl" env:"GEOIP_CACHE_TTL" help:"ttl for response cache" default:"${default_cache_ttl}"`
ReadHeaderTimeout string `name:"read-header-timeout" env:"GEOIP_READ_HEADER_TIMEOUT" help:"timeout for reading http header" default:"${default_read_header_timeout}"`
}
func (s *AppSettings) Parse() error {
ctx := kong.Parse(
s,
func (c *CLI) Parse() (*AppSettings, error) {
_ = kong.Parse(
c,
kong.Vars{
"default_address": ":8080",
"default_file_path": "./data.csv",
"default_address": ":8080",
"default_file_path": "./data.csv",
"default_cache_ttl": "2m",
"default_read_header_timeout": "3s",
},
kong.Name("country_geo_locations"),
kong.Description("🚀 Start a simple web server for GeoIP data"),
kong.UsageOnError(),
)
err := validateFlags(*s)
cacheTTL, err := time.ParseDuration(c.CacheTTL)
if err != nil {
ctx.PrintUsage(false)
fmt.Println()
return err
return nil, err
}
return nil
}
func validateFlags(settings AppSettings) error {
if settings.DataURL == "" {
return fmt.Errorf("error: invalid data url, must not be blank")
readHeaderTimeout, err := time.ParseDuration(c.ReadHeaderTimeout)
if err != nil {
return nil, err
}
return nil
if c.DataURL == "" {
return nil, ErrInvalidDataURL
}
return &AppSettings{
ServerAddress: c.ServerAddress,
DataFile: c.DataFile,
DataURL: c.DataURL,
CacheTTL: cacheTTL,
ReadHeaderTimeout: readHeaderTimeout,
}, nil
}

30
internal/cache/cache.go vendored Normal file
View file

@ -0,0 +1,30 @@
package cache
import (
"sync"
"time"
)
type Cache struct {
ttl time.Duration
store sync.Map
}
func NewCache(ttl time.Duration) *Cache {
return &Cache{
ttl: ttl,
}
}
func (c *Cache) Set(key, value uint) {
c.store.Store(key, value)
time.AfterFunc(c.ttl, func() {
c.store.Delete(key)
})
}
func (c *Cache) Get(key uint) (uint, bool) {
output, _ := c.store.Load(key)
value, ok := output.(uint)
return value, ok
}

View file

@ -1,32 +1,35 @@
package geoloc
package csvimporter
import (
"encoding/csv"
"os"
"strconv"
"git.ar21.de/yolokube/country-geo-locations/internal/database"
"git.ar21.de/yolokube/country-geo-locations/pkg/geoloc"
)
func parseCSV(filePath string) ([]IPInfo, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, err
func parseCSV(filePath string) ([]geoloc.IPInfo, error) {
file, fileErr := os.Open(filePath)
if fileErr != nil {
return nil, fileErr
}
defer file.Close()
reader := csv.NewReader(file)
var data []IPInfo
var data []geoloc.IPInfo
for {
record, err := reader.Read()
if err != nil {
break
}
ipnumfrom, err := strconv.Atoi(record[0])
ipnumfrom, err := strconv.ParseUint(record[0], 10, 64)
if err != nil {
continue
}
ipnumto, err := strconv.Atoi(record[1])
ipnumto, err := strconv.ParseUint(record[1], 10, 64)
if err != nil {
continue
}
@ -51,7 +54,7 @@ func parseCSV(filePath string) ([]IPInfo, error) {
longitude = 0
}
ipinfo := IPInfo{
ipinfo := geoloc.IPInfo{
IPNumFrom: uint(ipnumfrom),
IPNumTo: uint(ipnumto),
Code: record[2],
@ -68,12 +71,11 @@ func parseCSV(filePath string) ([]IPInfo, error) {
return data, nil
}
func ImportCSV(filePath string) error {
func ImportCSV(filePath string, db *database.Database) error {
ipinfos, err := parseCSV(filePath)
if err != nil {
return err
}
err = CreateDatabase(ipinfos)
return err
return db.Load(ipinfos)
}

View file

@ -0,0 +1,89 @@
package database
import (
"errors"
"git.ar21.de/yolokube/country-geo-locations/pkg/geoloc"
"github.com/hashicorp/go-memdb"
)
var (
ErrUnknownInterface = errors.New("unknown interface structure")
ErrIPNetNotFound = errors.New("IP net not found")
)
type Database struct {
db *memdb.MemDB
}
func NewDatabase() (*Database, error) {
database, err := memdb.NewMemDB(
&memdb.DBSchema{
Tables: map[string]*memdb.TableSchema{
"ipinfo": {
Name: "ipinfo",
Indexes: map[string]*memdb.IndexSchema{
"id": {
Name: "id",
Unique: true,
Indexer: &memdb.UintFieldIndex{Field: "IPNumFrom"},
},
},
},
},
},
)
if err != nil {
return nil, err
}
return &Database{
db: database,
}, nil
}
func (d *Database) Load(ipinfos []geoloc.IPInfo) error {
txn := d.db.Txn(true)
defer txn.Abort()
for _, ipinfo := range ipinfos {
if err := txn.Insert("ipinfo", ipinfo); err != nil {
return err
}
}
txn.Commit()
return nil
}
func (d *Database) SearchIPNet(ipnetnum uint) (*geoloc.IPInfo, error) {
txn := d.db.Txn(false)
defer txn.Abort()
var (
ipinfo geoloc.IPInfo
ok bool
)
for {
raw, err := txn.First("ipinfo", "id", ipnetnum)
if err != nil {
return nil, err
}
if raw != nil {
ipinfo, ok = raw.(geoloc.IPInfo)
if !ok {
return nil, ErrUnknownInterface
}
break
}
if ipnetnum == 0 {
return nil, ErrIPNetNotFound
}
ipnetnum -= geoloc.CalculationValue
}
return &ipinfo, nil
}

View file

@ -2,8 +2,8 @@ package downloader
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
@ -11,6 +11,12 @@ import (
"github.com/schollz/progressbar/v3"
)
var (
ErrAccessDenied = errors.New("restricted access (credentials required)")
ErrInvalidURL = errors.New("invalid url")
ErrUnknownAuthority = errors.New("certificate from unknown authority")
)
type Context struct {
Filename string
Link string
@ -27,22 +33,22 @@ func (c *Context) Download() error {
resp, err := req.Head(c.Link, nil)
if err != nil {
if strings.Contains(err.Error(), "no such host") {
return fmt.Errorf("invalid url")
return ErrInvalidURL
}
if strings.Contains(err.Error(), "certificate signed by unknown authority") {
return fmt.Errorf("certificate from unknown authority")
return ErrUnknownAuthority
}
return err
}
defer resp.Close()
if resp.StatusCode == 404 {
return fmt.Errorf("invalid url")
if resp.StatusCode == http.StatusNotFound {
return ErrInvalidURL
}
if resp.StatusCode == 401 {
return fmt.Errorf("restricted access (credentials required)")
if resp.StatusCode == http.StatusUnauthorized {
return ErrAccessDenied
}
if resp.StatusCode != 200 {
return fmt.Errorf(resp.RawResponse.Status)
if resp.StatusCode != http.StatusOK {
return errors.New(resp.RawResponse.Status)
}
resp.Close()

View file

@ -0,0 +1,118 @@
package downloader_test
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"git.ar21.de/yolokube/country-geo-locations/internal/downloader"
)
// TestNewContext tests the creation of a new Context.
func TestNewContext(t *testing.T) {
filename := "testfile"
link := "http://example.com"
ctx := downloader.NewContext(filename, link)
if ctx.Filename != filename {
t.Errorf("expected %s, got %s", filename, ctx.Filename)
}
if ctx.Link != link {
t.Errorf("expected %s, got %s", link, ctx.Link)
}
}
// TestDownload tests the Download function with different scenarios.
func TestDownload(t *testing.T) {
t.Run("ValidDownload", func(t *testing.T) {
// Mock server to simulate a valid download
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("This is the file content"))
}))
defer server.Close()
filename := "test_valid_download.txt"
ctx := downloader.NewContext(filename, server.URL)
defer os.Remove(filename)
err := ctx.Download()
if err != nil {
t.Errorf("expected no error, got %v", err)
}
if !ctx.FileExists() {
t.Errorf("expected file to exist, but it does not")
}
})
t.Run("InvalidURL", func(t *testing.T) {
ctx := downloader.NewContext("test_invalid_url.txt", "http://invalid.url")
err := ctx.Download()
if err == nil || err.Error() != "invalid url" {
t.Errorf("expected invalid url error, got %v", err)
}
})
t.Run("CertificateError", func(t *testing.T) {
// This test assumes a self-signed certificate or similar issue. This is hard to simulate in a unit test.
ctx := downloader.NewContext("test_cert_error.txt", "https://self-signed.badssl.com/") // Example URL that can be used
if err := ctx.Download(); err == nil || err.Error() != "certificate from unknown authority" {
t.Errorf("expected certificate from unknown authority error, got %v", err)
}
})
t.Run("FileNotFound", func(t *testing.T) {
// Mock server to simulate a 404 response
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
ctx := downloader.NewContext("test_not_found.txt", server.URL)
if err := ctx.Download(); err == nil || err.Error() != "invalid url" {
t.Errorf("expected invalid url error, got %v", err)
}
})
t.Run("RestrictedAccess", func(t *testing.T) {
// Mock server to simulate a 401 response
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
ctx := downloader.NewContext("test_restricted_access.txt", server.URL)
if err := ctx.Download(); err == nil || err.Error() != "restricted access (credentials required)" {
t.Errorf("expected restricted access error, got %v", err)
}
})
}
// TestFileExists tests the FileExists function.
func TestFileExists(t *testing.T) {
filename := "testfile_exists.txt"
ctx := downloader.NewContext(filename, "http://example.com")
// Ensure file does not exist initially
if ctx.FileExists() {
t.Errorf("expected file to not exist, but it does")
}
// Create a file and check again
file, err := os.Create(filename)
if err != nil {
t.Fatalf("failed to create test file: %v", err)
}
file.Close()
defer os.Remove(filename)
if !ctx.FileExists() {
t.Errorf("expected file to exist, but it does not")
}
}

56
main.go
View file

@ -1,45 +1,54 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
api_v1 "git.ar21.de/yolokube/country-geo-locations/api/v1"
apiv1 "git.ar21.de/yolokube/country-geo-locations/api/v1"
"git.ar21.de/yolokube/country-geo-locations/cfg"
"git.ar21.de/yolokube/country-geo-locations/pkg/downloader"
"git.ar21.de/yolokube/country-geo-locations/pkg/geoloc"
"git.ar21.de/yolokube/country-geo-locations/internal/cache"
csvimporter "git.ar21.de/yolokube/country-geo-locations/internal/csv_importer"
"git.ar21.de/yolokube/country-geo-locations/internal/database"
"git.ar21.de/yolokube/country-geo-locations/internal/downloader"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/render"
)
func main() {
appSettings := cfg.NewAppSettings()
if err := appSettings.Parse(); err != nil {
panic(err)
cli := cfg.CLI{}
appSettings, err := cli.Parse()
if err != nil {
log.Fatal(err)
}
handleGracefulShutdown()
ctx := downloader.NewContext(appSettings.DataFile, appSettings.DataURL)
if !ctx.FileExists() {
if err := ctx.Download(); err != nil {
panic(err)
if downloadErr := ctx.Download(); downloadErr != nil {
log.Fatal(downloadErr)
}
fmt.Printf("Saved file to %s\n", ctx.Filename)
log.Printf("saved file to %s\n", ctx.Filename)
}
fmt.Printf("Import data from file...\r")
err := geoloc.ImportCSV(appSettings.DataFile)
db, err := database.NewDatabase()
if err != nil {
fmt.Println("Import data from file failed")
panic(err)
log.Fatal("database creation failed", err)
}
fmt.Println("Import data from file successful")
log.Println("importing data from file", appSettings.DataFile)
err = csvimporter.ImportCSV(appSettings.DataFile, db)
if err != nil {
log.Fatal("data Import from file failed", err)
}
log.Println("imported data from file successful")
cache := cache.NewCache(appSettings.CacheTTL)
lh := apiv1.NewLocationHandler(cache, db)
r := chi.NewRouter()
r.Use(middleware.RequestID)
@ -47,17 +56,24 @@ func main() {
r.Use(middleware.Recoverer)
r.Use(render.SetContentType(render.ContentTypeJSON))
r.Mount("/api/v1", api_v1.ApiRouter())
r.Mount("/api/v1", apiv1.NewRouter(lh))
log.Printf("starting server at %s\n", appSettings.ServerAddress)
http.ListenAndServe(appSettings.ServerAddress, r)
server := &http.Server{
Addr: appSettings.ServerAddress,
Handler: r,
ReadHeaderTimeout: appSettings.ReadHeaderTimeout,
}
log.Println("starting server at", server.Addr)
if err = server.ListenAndServe(); err != nil {
log.Panic(err)
}
}
func handleGracefulShutdown() {
var signals = make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGTERM)
signal.Notify(signals, syscall.SIGINT)
signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT)
go func() {
sig := <-signals

View file

@ -1,50 +0,0 @@
package geoloc
import (
"sync"
"time"
)
var ipCache *Cache
type Cache struct {
store sync.Map
}
func NewCache() *Cache {
return &Cache{}
}
func (c *Cache) Set(key, value uint, ttl time.Duration) {
c.store.Store(key, value)
time.AfterFunc(ttl, func() {
c.store.Delete(key)
})
}
func (c *Cache) Get(key uint) (uint, bool) {
output, _ := c.store.Load(key)
value, ok := output.(uint)
return value, ok
}
func createCache() {
ipCache = NewCache()
}
func GetCacheContent(key uint) (uint, bool) {
if ipCache == nil {
createCache()
return 0, false
}
return ipCache.Get(key)
}
func SetCacheContent(key, value uint, ttl time.Duration) {
if ipCache == nil {
createCache()
}
ipCache.Set(key, value, ttl)
}

View file

@ -1,65 +0,0 @@
package geoloc
import (
"fmt"
"github.com/hashicorp/go-memdb"
)
var database *memdb.MemDB
func CreateDatabase(ipinfos []IPInfo) (err error) {
schema := &memdb.DBSchema{
Tables: map[string]*memdb.TableSchema{
"ipinfo": {
Name: "ipinfo",
Indexes: map[string]*memdb.IndexSchema{
"id": {
Name: "id",
Unique: true,
Indexer: &memdb.UintFieldIndex{Field: "IPNumFrom"},
},
},
},
},
}
database, err = memdb.NewMemDB(schema)
if err != nil {
return
}
txn := database.Txn(true)
defer txn.Abort()
for _, ipinfo := range ipinfos {
if err = txn.Insert("ipinfo", ipinfo); err != nil {
return
}
}
txn.Commit()
return
}
func SearchIPNet(ipnetnum uint) (*IPInfo, error) {
txn := database.Txn(false)
defer txn.Abort()
var ipinfo IPInfo
for {
raw, err := txn.First("ipinfo", "id", ipnetnum)
if err != nil {
return nil, err
}
if raw != nil {
ipinfo = raw.(IPInfo)
break
}
if ipnetnum == 0 {
return nil, fmt.Errorf("SearchIPNet: IP net not found")
}
ipnetnum = ipnetnum - 256
}
return &ipinfo, nil
}

View file

@ -1,5 +1,7 @@
package geoloc
const CalculationValue uint = 256
type IPInfo struct {
IPNumFrom uint `json:"ip_num_min"`
IPNumTo uint `json:"ip_num_max"`

View file

@ -1,22 +1,24 @@
package geoloc
import (
"fmt"
"errors"
"math"
"net"
"github.com/praserx/ipconv"
)
func GetIPInfo(ipaddress string) (uint, error) {
var ErrNoValidIP = errors.New("no valid IP address")
func CalculateIPNum(ipaddress string) (uint, error) {
ip := net.ParseIP(ipaddress)
if ip == nil {
return 0, fmt.Errorf("no valid IP address")
return 0, ErrNoValidIP
}
ipnum, err := ipconv.IPv4ToInt(ip)
if err != nil {
return 0, err
}
ipnumfrom := uint(math.Floor(float64(ipnum)/float64(256))) * 256
ipnumfrom := uint(math.Floor(float64(ipnum)/float64(CalculationValue))) * CalculationValue
return ipnumfrom, nil
}

View file

@ -1,8 +1,10 @@
package geoloc
package geoloc_test
import (
"reflect"
"testing"
"git.ar21.de/yolokube/country-geo-locations/pkg/geoloc"
)
func TestGetIPInfo(t *testing.T) {
@ -18,7 +20,7 @@ func TestGetIPInfo(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got, err := GetIPInfo(tt.args.input); err != nil || !reflect.DeepEqual(got, tt.want) {
if got, err := geoloc.CalculateIPNum(tt.args.input); err != nil || !reflect.DeepEqual(got, tt.want) {
t.Errorf("GetIPInfo() - %v, want %v", got, tt.want)
}
})