diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97706c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bin/ +age-api diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..6e566a3 --- /dev/null +++ b/.woodpecker/build.yaml @@ -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 diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml new file mode 100644 index 0000000..a4e37d9 --- /dev/null +++ b/.woodpecker/docker.yaml @@ -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 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..2dd88b8 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,9 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: golang:1.25 + commands: + - test -z "$(gofmt -l .)" + - go vet ./... diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..ce25edf --- /dev/null +++ b/.woodpecker/test.yaml @@ -0,0 +1,8 @@ +when: + - event: pull_request + +steps: + - name: test + image: golang:1.25 + commands: + - go test -race -count=1 ./... diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c698856 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3b26591 --- /dev/null +++ b/Makefile @@ -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) diff --git a/README.md b/README.md index efac16c..7fa12c0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,59 @@ # age-api -Simple API for showing a users age \ No newline at end of file +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 +``` diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..d4c77be --- /dev/null +++ b/config.yaml @@ -0,0 +1,7 @@ +people: + - name: alice + birthtime: 642729600 + - name: bob + birthtime: 501465600 + - name: charlie + birthtime: 946684800 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..12a48ca --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module age-api + +go 1.25.10 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..ff652d9 --- /dev/null +++ b/main.go @@ -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)) +}