Merge pull request 'Initial commit' (#1) from benvin/features into main
ci/woodpecker/tag/docker Pipeline was successful
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
bin/
|
||||
age-api
|
||||
@@ -0,0 +1,9 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
steps:
|
||||
- name: docker-build
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
repo: git.unkin.net/unkin/age-api
|
||||
dry_run: true
|
||||
@@ -0,0 +1,16 @@
|
||||
when:
|
||||
- event: tag
|
||||
ref: refs/tags/v*
|
||||
|
||||
steps:
|
||||
- name: docker
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
registry: git.unkin.net
|
||||
repo: git.unkin.net/unkin/age-api
|
||||
username: droneci
|
||||
password:
|
||||
from_secret: DRONECI_PASSWORD
|
||||
tags:
|
||||
- ${CI_COMMIT_TAG}
|
||||
- latest
|
||||
@@ -0,0 +1,9 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
steps:
|
||||
- name: pre-commit
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- test -z "$(gofmt -l .)"
|
||||
- go vet ./...
|
||||
@@ -0,0 +1,8 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
steps:
|
||||
- name: test
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- go test -race -count=1 ./...
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o age-api .
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
COPY --from=builder /build/age-api /usr/local/bin/age-api
|
||||
COPY --from=builder /build/config.yaml /etc/age-api/config.yaml
|
||||
|
||||
ENV CONFIG_PATH=/etc/age-api/config.yaml
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["age-api"]
|
||||
@@ -0,0 +1,57 @@
|
||||
.PHONY: build test lint fmt docker clean tidy check-go patch minor major _tag
|
||||
|
||||
BINARY := bin/age-api
|
||||
MODULE := age-api
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev")
|
||||
GO_VERSION_REQUIRED := 1.25
|
||||
GO_VERSION_ACTUAL := $(shell go version | sed 's/go version go\([0-9]*\.[0-9]*\).*/\1/')
|
||||
|
||||
check-go:
|
||||
@if [ "$$(printf '%s\n%s' "$(GO_VERSION_REQUIRED)" "$(GO_VERSION_ACTUAL)" | sort -V | head -1)" != "$(GO_VERSION_REQUIRED)" ]; then \
|
||||
echo "ERROR: Go >= $(GO_VERSION_REQUIRED) required, found $(GO_VERSION_ACTUAL)"; exit 1; \
|
||||
fi
|
||||
|
||||
build: check-go tidy
|
||||
go build -ldflags="-s -w" -o $(BINARY) .
|
||||
|
||||
test: check-go
|
||||
go test -race -count=1 ./...
|
||||
|
||||
lint: check-go
|
||||
golangci-lint run ./...
|
||||
go vet ./...
|
||||
|
||||
fmt: check-go
|
||||
gofmt -w .
|
||||
goimports -w .
|
||||
|
||||
docker:
|
||||
docker build -t age-api:$(VERSION) .
|
||||
|
||||
clean:
|
||||
rm -rf bin/
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
# Bump helpers
|
||||
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
|
||||
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
|
||||
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
|
||||
_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
|
||||
_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
|
||||
|
||||
patch:
|
||||
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
|
||||
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
|
||||
|
||||
minor:
|
||||
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
|
||||
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
|
||||
|
||||
major:
|
||||
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
|
||||
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
|
||||
|
||||
_tag:
|
||||
git push origin $(TAG)
|
||||
@@ -1,3 +1,59 @@
|
||||
# age-api
|
||||
|
||||
Simple API for showing a users age
|
||||
A simple REST API that calculates age breakdowns for configured people.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET /age`
|
||||
|
||||
Returns age breakdowns for all configured people.
|
||||
|
||||
### `GET /age/{name}`
|
||||
|
||||
Returns the age breakdown for a single person by name (case-insensitive).
|
||||
|
||||
### Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "alice",
|
||||
"dob": "1990-05-15",
|
||||
"birthtime": 642988800,
|
||||
"age": {
|
||||
"years": 36,
|
||||
"months": 433,
|
||||
"weeks": 1889,
|
||||
"days": 13222,
|
||||
"hours": 317328,
|
||||
"minutes": 19039680,
|
||||
"seconds": 1142380800
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
People are defined in `config.yaml` with `birthtime` as a Unix timestamp:
|
||||
|
||||
```yaml
|
||||
people:
|
||||
- name: alice
|
||||
birthtime: 642729600
|
||||
- name: bob
|
||||
birthtime: 501465600
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `CONFIG_PATH` | `config.yaml` | Path to the configuration file |
|
||||
| `LISTEN_ADDR` | `:8080` | Address and port to listen on |
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
make build # build binary to bin/age-api
|
||||
make test # run tests
|
||||
make docker # build docker image
|
||||
```
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
people:
|
||||
- name: alice
|
||||
birthtime: 642729600
|
||||
- name: bob
|
||||
birthtime: 501465600
|
||||
- name: charlie
|
||||
birthtime: 946684800
|
||||
@@ -0,0 +1,4 @@
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
People []Person `yaml:"people"`
|
||||
}
|
||||
|
||||
type Person struct {
|
||||
Name string `yaml:"name"`
|
||||
Birthtime int64 `yaml:"birthtime"`
|
||||
}
|
||||
|
||||
type AgeBreakdown struct {
|
||||
Years int `json:"years"`
|
||||
Months int `json:"months"`
|
||||
Weeks int `json:"weeks"`
|
||||
Days int `json:"days"`
|
||||
Hours int64 `json:"hours"`
|
||||
Minutes int64 `json:"minutes"`
|
||||
Seconds int64 `json:"seconds"`
|
||||
}
|
||||
|
||||
type PersonAge struct {
|
||||
Name string `json:"name"`
|
||||
DOB string `json:"dob"`
|
||||
Birthtime int64 `json:"birthtime"`
|
||||
SleepsUntilBirthday int `json:"sleeps_until_birthday"`
|
||||
Age AgeBreakdown `json:"age"`
|
||||
}
|
||||
|
||||
func sleepsUntilBirthday(dob time.Time, now time.Time) int {
|
||||
next := time.Date(now.Year(), dob.Month(), dob.Day(), 0, 0, 0, 0, now.Location())
|
||||
if !next.After(now) {
|
||||
next = time.Date(now.Year()+1, dob.Month(), dob.Day(), 0, 0, 0, 0, now.Location())
|
||||
}
|
||||
return int(next.Sub(now).Hours()/24) + 1
|
||||
}
|
||||
|
||||
func calculateAge(dob time.Time, now time.Time) AgeBreakdown {
|
||||
// Calendar-based years and months
|
||||
years := now.Year() - dob.Year()
|
||||
months := int(now.Month()) - int(dob.Month())
|
||||
if now.Day() < dob.Day() {
|
||||
months--
|
||||
}
|
||||
if months < 0 {
|
||||
years--
|
||||
months += 12
|
||||
}
|
||||
|
||||
totalMonths := years*12 + months
|
||||
|
||||
// Total duration for weeks/days/hours/minutes/seconds
|
||||
dur := now.Sub(dob)
|
||||
totalDays := int(dur.Hours() / 24)
|
||||
totalWeeks := totalDays / 7
|
||||
|
||||
return AgeBreakdown{
|
||||
Years: years,
|
||||
Months: totalMonths,
|
||||
Weeks: totalWeeks,
|
||||
Days: totalDays,
|
||||
Hours: int64(dur.Hours()),
|
||||
Minutes: int64(dur.Minutes()),
|
||||
Seconds: int64(dur.Seconds()),
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading config: %w", err)
|
||||
}
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing config: %w", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
configPath := "config.yaml"
|
||||
if v := os.Getenv("CONFIG_PATH"); v != "" {
|
||||
configPath = v
|
||||
}
|
||||
|
||||
cfg, err := loadConfig(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// Index people by lowercase name
|
||||
people := make(map[string]Person)
|
||||
for _, p := range cfg.People {
|
||||
people[strings.ToLower(p.Name)] = p
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// GET /age - all people
|
||||
mux.HandleFunc("GET /age", func(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now()
|
||||
var results []PersonAge
|
||||
for _, p := range cfg.People {
|
||||
dob := time.Unix(p.Birthtime, 0)
|
||||
results = append(results, PersonAge{
|
||||
Name: p.Name,
|
||||
DOB: dob.Format("2006-01-02"),
|
||||
Birthtime: p.Birthtime,
|
||||
SleepsUntilBirthday: sleepsUntilBirthday(dob, now),
|
||||
Age: calculateAge(dob, now),
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(results)
|
||||
})
|
||||
|
||||
// GET /age/{name} - single person
|
||||
mux.HandleFunc("GET /age/{name}", func(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.ToLower(r.PathValue("name"))
|
||||
p, ok := people[name]
|
||||
if !ok {
|
||||
http.Error(w, fmt.Sprintf("person %q not found", name), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
dob := time.Unix(p.Birthtime, 0)
|
||||
result := PersonAge{
|
||||
Name: p.Name,
|
||||
DOB: dob.Format("2006-01-02"),
|
||||
Birthtime: p.Birthtime,
|
||||
SleepsUntilBirthday: sleepsUntilBirthday(dob, now),
|
||||
Age: calculateAge(dob, now),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
|
||||
addr := ":8080"
|
||||
if v := os.Getenv("LISTEN_ADDR"); v != "" {
|
||||
addr = v
|
||||
}
|
||||
log.Printf("Listening on %s", addr)
|
||||
log.Fatal(http.ListenAndServe(addr, mux))
|
||||
}
|
||||
Reference in New Issue
Block a user