Initial commit
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful

- 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:
2026-06-21 23:24:12 +10:00
parent 8776a487f9
commit d45111645c
12 changed files with 353 additions and 1 deletions
+2
View File
@@ -0,0 +1,2 @@
bin/
age-api
+9
View File
@@ -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
+16
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: golang:1.25
commands:
- test -z "$(gofmt -l .)"
- go vet ./...
+8
View File
@@ -0,0 +1,8 @@
when:
- event: pull_request
steps:
- name: test
image: golang:1.25
commands:
- go test -race -count=1 ./...
+23
View File
@@ -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"]
+57
View File
@@ -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)
+57 -1
View File
@@ -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
```
+7
View File
@@ -0,0 +1,7 @@
people:
- name: alice
birthtime: 642729600
- name: bob
birthtime: 501465600
- name: charlie
birthtime: 946684800
+5
View File
@@ -0,0 +1,5 @@
module age-api
go 1.25.10
require gopkg.in/yaml.v3 v3.0.1
+4
View File
@@ -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=
+156
View File
@@ -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))
}