Initial commit
- REST API for calculating age breakdowns (years, months, weeks, days, hours, minutes, seconds)
- Birthtime configured as Unix timestamps
- Sleeps until next birthday countdown
- Per-person lookup via GET /age/{name}
- Docker and Makefile build support
- Woodpecker CI pipelines
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
|
# 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