Compare commits
13
Commits
v0.1.0
..
04770492d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04770492d5 | ||
|
|
bf357015bc | ||
|
|
383c889cab | ||
|
|
e5468ef654 | ||
|
|
e30c25b198 | ||
|
|
b8fde634b5 | ||
|
|
352dfcf2b6 | ||
|
|
df5c189743 | ||
|
|
923672a418 | ||
|
|
3e545deb44 | ||
|
|
d7191461eb | ||
|
|
f9e8f4b9c1 | ||
|
|
949a30e001 |
@@ -1,2 +0,0 @@
|
||||
domogeek
|
||||
.git
|
||||
@@ -0,0 +1,2 @@
|
||||
/domogeek
|
||||
.git
|
||||
+1
-1
@@ -108,5 +108,5 @@ tags
|
||||
|
||||
|
||||
# Binary
|
||||
domogeek
|
||||
/domogeek
|
||||
build/
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
domogeek is a small Go HTTP service that exposes calendar/holiday information for home automation (domotic) use. It answers whether "today" is a working day, a French public holiday (`ferie`), and/or a personal holiday read from a CalDAV calendar. The same information is also exposed as MCP tools over a Streamable HTTP endpoint (`/mcp`), so LLM agents/MCP clients can query it directly.
|
||||
|
||||
## Commands
|
||||
|
||||
Build and test use Go's standard tooling with vendored dependencies (`-mod=vendor`). The module requires **Go 1.25+** (bumped from 1.18 to use the official MCP SDK). If the `go` binary on `PATH` resolves to an older toolchain (e.g. this box also has `golang-1.18` installed and it can shadow the newer one), invoke a newer toolchain explicitly, overriding `GOROOT` if it's pinned to the old one (e.g. by an IDE's `_INTELLIJ_FORCE_SET_GOROOT`):
|
||||
|
||||
```bash
|
||||
GOROOT=/usr/lib/go-1.27 /usr/lib/go-1.27/bin/go build -mod=vendor ./...
|
||||
```
|
||||
|
||||
Otherwise, plain invocations work the same as before:
|
||||
|
||||
```bash
|
||||
go build -mod=vendor ./... # build
|
||||
go vet -mod=vendor ./... # vet
|
||||
go test -mod=vendor ./... # run all tests
|
||||
go test -mod=vendor ./pkg/calendar/ -run TestCalendar_IsWorkingDay -v # run a single test
|
||||
```
|
||||
|
||||
Run locally (caldav flags are optional — omitting them disables CalDAV-based holiday checks):
|
||||
|
||||
```bash
|
||||
go run -mod=vendor ./cmd/domogeek \
|
||||
-port 8080 \
|
||||
-caldav-url https://example.com \
|
||||
-caldav-path /calendars/user/holidays/ \
|
||||
-caldav-username user \
|
||||
-caldav-password pass \
|
||||
-caldav-summary-pattern Holidays
|
||||
```
|
||||
|
||||
Running with no flags at all prints usage and exits (see the `len(os.Args) <= 1` check in `main`). Note: despite the "caldav flags are optional" framing, `main()` currently calls `calendar.NewCaldav` unconditionally, even with an empty `-caldav-url` — with no caldav server to validate against, this hits the 1000-retry exponential backoff described below and the process never gets to `http.ListenAndServe`. This is a pre-existing quirk, not something introduced by the MCP work; if you need a quick local instance (e.g. to exercise `/mcp` or `/calendar` without a real CalDAV server), it's easiest to call `newMCPHandler()`/`calendar.New(location)` directly from a small throwaway `main`/test rather than running the built binary.
|
||||
|
||||
Docker image (multi-stage, distroless), for local testing:
|
||||
|
||||
```bash
|
||||
docker build -t domogeek . # or: buildah bud -t domogeek .
|
||||
```
|
||||
|
||||
## Continuous builds
|
||||
|
||||
Pushing a Git tag matching `v[0-9]*` (existing convention: `vX.Y.Z`, e.g. `v0.4.0`) to
|
||||
the Gitea remote triggers the homelab Tekton pipeline `gitea-docker-build-multiarch`,
|
||||
which builds and pushes a multi-arch (amd64 + arm64) image plus a signed CycloneDX SBOM:
|
||||
|
||||
```
|
||||
git.cyrilix.bzh/cyrilix/domogeek:<tag>
|
||||
```
|
||||
|
||||
The pipeline uses its defaults — `Dockerfile` at the repo root, build context `.` — so
|
||||
the Dockerfile must stay at the root. It builds each target platform natively (no
|
||||
`GOOS`/`GOARCH` overrides in the `go build` step), so the Dockerfile must not hardcode a
|
||||
single-arch `GOOS`/`GOARCH` — that was a real bug fixed alongside this: the builder image
|
||||
is now pinned to `golang:1.27-alpine` (matching the `go 1.25` module requirement) and the
|
||||
`go build` step targets the whole package (`./cmd/domogeek`), not a single file.
|
||||
|
||||
There is no Tekton YAML in this repo: the pipeline and its Gitea webhook trigger are
|
||||
defined cluster-side. Watch a run: <https://tekton.banquise.cyrilix.bzh>
|
||||
|
||||
```sh
|
||||
git tag v0.4.0
|
||||
git push --tags
|
||||
```
|
||||
|
||||
Note: this replaces the previous `docker.io/cyrilix/domogeek:<git describe>` releases
|
||||
built locally via the now-removed `build-docker.sh`/`buildah` script.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `cmd/domogeek/domogeek.go` — the binary entrypoint. Wires up flags, zap logging, the `calendar.Calendar`, Prometheus metrics/instrumented handlers, and a `health-go` health check, then serves five HTTP routes:
|
||||
- `GET /calendar` — returns a `CalendarDay` JSON payload (`working_day`, `ferie`, `holiday`, `weekday`) for "now" in `Europe/Paris`.
|
||||
- `GET /datetime` — returns a `CurrentDateTime` JSON payload (`date_time`) for "now" in `Europe/Paris` (`DateTimeHandler`).
|
||||
- `POST /mcp` — MCP Streamable HTTP endpoint (see `cmd/domogeek/mcp.go` below).
|
||||
- `GET /metrics` — Prometheus metrics (request counter/summary/histogram wrapping the calendar handler, defined in `init()`).
|
||||
- `GET /status` — health-go handler with two checks: a static `calendar` check and a `caldav` check that calls `cal.IsHolidaysFromCaldav`.
|
||||
- The process blocks on `SIGTERM` (not `SIGINT`) via a signal channel; `http.ListenAndServe` runs in a goroutine and `zap.S().Fatal`s the process on error.
|
||||
|
||||
- `cmd/domogeek/mcp.go` — MCP server built with `github.com/modelcontextprotocol/go-sdk/mcp`, mounted at `/mcp` via `mcp.NewStreamableHTTPHandler`. Exposes four tools, all reading the same package-level `cal`/`location` globals as the `/calendar` and `/datetime` handlers:
|
||||
- `get_current_datetime` — same payload as `GET /datetime`: the current date and time.
|
||||
- `get_calendar_today` — same payload as `GET /calendar`, for now.
|
||||
- `get_calendar_for_date` — same payload for an arbitrary `date` (`YYYY-MM-DD`); an invalid date is returned as a tool error (`CallToolResult.IsError`), not a protocol-level error, so an MCP client/LLM can see and self-correct.
|
||||
- `get_holidays` — lists French public holidays (`Calendar.GetHolidays`) for a given `year`, defaulting to the current year if omitted.
|
||||
- `calendarDayFor` in this file is the single place that builds a `CalendarDay` from a `time.Time`; both the HTTP `/calendar` handler and the MCP tools call it, so there's one code path for the working-day/ferie/holiday/weekday logic.
|
||||
- `cmd/domogeek/mcp_test.go` unit-tests the handler functions directly; `cmd/domogeek/mcp_e2e_test.go` drives the real Streamable HTTP transport with an MCP client (`httptest.Server` + `mcp.NewClient(...).Connect`) to check the wiring end-to-end (tool listing, structured content, and the tool-error-vs-protocol-error distinction). `cmd/domogeek/datetime_test.go` covers the `/datetime` HTTP handler directly.
|
||||
|
||||
- `pkg/calendar/calendar.go` — all domain logic, built around the `Calendar` struct and functional options (`WithCaldav`, `WithCaldavPath`, `WithCaldavSummaryPattern`):
|
||||
- `GetEasterDay` implements the Gauss/Meeus algorithm to compute Easter Sunday for a given year (in `cal.Location`); `GetHolidays` derives the fixed-date French public holidays plus two computed from it: Easter Monday (Easter+1) and Ascension (Easter+39).
|
||||
- `IsHoliday` combines the static French holiday set with `IsHolidaysFromCaldav`.
|
||||
- `IsHolidaysFromCaldav` queries a CalDAV calendar for events on a given day and treats any event whose `Summary` contains `caldavSummaryPattern` (default `"Holidays"`) as a personal holiday. If no `Caldav` client is configured, it returns `false, nil` — CalDAV is optional.
|
||||
- The `Caldav` interface (`QueryEvents`) abstracts `github.com/dolanor/caldav-go`, letting tests substitute `MockCaldav` (see `calendar_test.go`) instead of hitting a real server.
|
||||
- `NewCaldav` validates the CalDAV connection at startup with `avast/retry-go`, retrying up to 1000 times with exponential backoff (max delay 24h) — this means a bad/unreachable CalDAV server does **not** fail fast; `main()` will effectively hang retrying rather than exiting quickly if the caldav server is down (it's only `Fatal`'d if `NewCaldav` itself returns a non-nil error, which currently only happens after all retries are exhausted).
|
||||
|
||||
- `pkg/metrics/metrics.go` — currently a single unused/unconfigured Prometheus counter (empty `Namespace`/`Subsystem`/`Name`/`Help`). Not wired into `cmd/domogeek`; treat as in-progress scaffolding rather than an established pattern to follow.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Dependencies are vendored under `vendor/` and checked into the repo — always pass `-mod=vendor` (or rely on `GOFLAGS=-mod=vendor` if set) for build/test/vet so Go doesn't try to hit the network or the module cache. After changing `go.mod`/`go.sum`, run `go mod vendor` to keep `vendor/` in sync (use the newer toolchain per the note above, and `-mod=mod` instead of `-mod=vendor` for the `go get`/`go mod tidy` step itself).
|
||||
|
||||
`github.com/modelcontextprotocol/go-sdk` (the official Go MCP SDK) is the dependency added for the `/mcp` endpoint; it pulled in `google/jsonschema-go`, `segmentio/encoding`, `yosida95/uritemplate`, and a few `golang.org/x/*` packages as transitive deps, and required bumping the `go` directive in `go.mod` from `1.18` to `1.25.0`.
|
||||
+2
-5
@@ -1,11 +1,8 @@
|
||||
FROM golang:alpine as builder
|
||||
FROM golang:1.27-alpine AS builder
|
||||
|
||||
WORKDIR /go/src
|
||||
ADD . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=vendor -tags netgo -o /go/bin/domogeek cmd/domogeek/domogeek.go
|
||||
|
||||
|
||||
|
||||
RUN CGO_ENABLED=0 go build -mod=vendor -tags netgo -o /go/bin/domogeek ./cmd/domogeek
|
||||
|
||||
FROM gcr.io/distroless/static
|
||||
|
||||
|
||||
@@ -6,3 +6,30 @@ Tools for domotic
|
||||
|
||||
Return calendar informations about today
|
||||
|
||||
## Date and time
|
||||
|
||||
`GET /datetime` returns the current date and time.
|
||||
|
||||
## MCP
|
||||
|
||||
An MCP server is exposed over Streamable HTTP at `/mcp`, providing the same calendar
|
||||
and date/time information as MCP tools (`get_current_datetime`, `get_calendar_today`,
|
||||
`get_calendar_for_date`, `get_holidays`) for use by LLM agents/MCP clients.
|
||||
|
||||
## Continuous builds
|
||||
|
||||
Pushing a Git tag matching `v[0-9]*` (e.g. `v0.4.0`) to the Gitea remote triggers the
|
||||
homelab Tekton pipeline `gitea-docker-build-multiarch`, which builds and pushes a
|
||||
multi-arch (amd64 + arm64) image plus a signed SBOM:
|
||||
|
||||
```
|
||||
git.cyrilix.bzh/cyrilix/domogeek:<tag>
|
||||
```
|
||||
|
||||
```sh
|
||||
git tag v0.4.0
|
||||
git push --tags
|
||||
```
|
||||
|
||||
Watch the run: <https://tekton.banquise.cyrilix.bzh>
|
||||
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Calendar struct {
|
||||
Location *time.Location
|
||||
}
|
||||
|
||||
func (cal *Calendar) GetEasterDay(year int) time.Time {
|
||||
g := float64(year % 19.0)
|
||||
c := math.Floor(float64(year) / 100.0)
|
||||
c4 := math.Floor(c / 4.0)
|
||||
h := float64(int(19.0*g+c-c4-math.Floor((8.0*c+13)/25)+15) % 30.0)
|
||||
k := math.Floor(h / 28.0)
|
||||
i := (k*math.Floor(29./(h+1.))*math.Floor((21.-g)/11.)-1.)*k + h
|
||||
|
||||
// jour de Pâques (0=dimanche, 1=lundi....)
|
||||
dayWeek := int(math.Floor(float64(year)/4.)+float64(year)+i+2+c4-c) % 7
|
||||
|
||||
// Jour de Pâques en jours enpartant de 1 = 1er mars
|
||||
presJour := int(28 + int(i) - dayWeek)
|
||||
|
||||
// mois (0 = janvier, ... 2 = mars, 3 = avril)
|
||||
month := 2
|
||||
if presJour > 31 {
|
||||
month = 3
|
||||
}
|
||||
|
||||
// Mois dans l'année
|
||||
month += 1
|
||||
|
||||
// jour du mois
|
||||
day := presJour - 31
|
||||
if month == 2 {
|
||||
day = presJour
|
||||
}
|
||||
|
||||
return time.Date(year, 3, 31, 0, 0, 0, 0, cal.Location).AddDate(0, 0, day)
|
||||
}
|
||||
|
||||
func (cal *Calendar) GetHolidays(year int) *[]time.Time {
|
||||
|
||||
// Calcul du jour de pâques
|
||||
paques := cal.GetEasterDay(year)
|
||||
|
||||
joursFeries := []time.Time{
|
||||
// Jour de l'an
|
||||
time.Date(year, time.January, 1, 0, 0, 0, 0, cal.Location),
|
||||
// Easter
|
||||
paques,
|
||||
// 1 mai
|
||||
time.Date(year, time.May, 1, 0, 0, 0, 0, cal.Location),
|
||||
// 8 mai
|
||||
time.Date(year, time.May, 8, 0, 0, 0, 0, cal.Location),
|
||||
// Ascension
|
||||
paques.AddDate(0, 0, 39),
|
||||
// 14 juillet
|
||||
time.Date(year, time.July, 14, 0, 0, 0, 0, cal.Location),
|
||||
// 15 aout
|
||||
time.Date(year, time.August, 15, 0, 0, 0, 0, cal.Location),
|
||||
// Toussaint
|
||||
time.Date(year, time.November, 1, 0, 0, 0, 0, cal.Location),
|
||||
// 11 novembre
|
||||
time.Date(year, time.November, 11, 0, 0, 0, 0, cal.Location),
|
||||
// noël
|
||||
time.Date(year, time.December, 25, 0, 0, 0, 0, cal.Location),
|
||||
}
|
||||
|
||||
return &joursFeries
|
||||
}
|
||||
|
||||
func (cal *Calendar) GetHolidaysSet(year int) *map[time.Time]bool {
|
||||
holidays := cal.GetHolidays(year)
|
||||
result := make(map[time.Time]bool, len(*holidays))
|
||||
for _, h := range *holidays {
|
||||
result[h] = true
|
||||
}
|
||||
return &result
|
||||
}
|
||||
|
||||
func(cal *Calendar) IsHoliday(date time.Time) bool{
|
||||
h := cal.GetHolidaysSet(date.Year())
|
||||
d := date.In(cal.Location)
|
||||
day := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, cal.Location)
|
||||
return (*h)[day]
|
||||
}
|
||||
|
||||
func (cal *Calendar) IsWorkingDay(date time.Time) bool {
|
||||
return !cal.IsHoliday(date) && date.Weekday() >= time.Monday && date.Weekday() <= time.Friday
|
||||
}
|
||||
|
||||
func (cal *Calendar) IsWorkingDayToday() bool {
|
||||
return cal.IsWorkingDay(time.Now())
|
||||
}
|
||||
|
||||
func (cal *Calendar) IsWeekDay(day time.Time) bool{
|
||||
return day.Weekday() >= time.Monday && day.Weekday() <= time.Friday
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDateTimeHandler_ServeHTTP(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/datetime", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
(&DateTimeHandler{}).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var got CurrentDateTime
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("unable to decode response: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().In(location)
|
||||
if diff := now.Sub(got.DateTime); diff < -time.Second || diff > time.Second {
|
||||
t.Errorf("DateTime = %v, want close to %v", got.DateTime, now)
|
||||
}
|
||||
_, wantOffset := now.Zone()
|
||||
_, gotOffset := got.DateTime.Zone()
|
||||
if gotOffset != wantOffset {
|
||||
t.Errorf("DateTime UTC offset = %d, want %d", gotOffset, wantOffset)
|
||||
}
|
||||
}
|
||||
+100
-19
@@ -2,22 +2,27 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"domogeek/calendar"
|
||||
"domogeek/pkg/calendar"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
|
||||
"github.com/hellofresh/health-go/v4"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"go.uber.org/zap"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
cal calendar.Calendar
|
||||
cal *calendar.Calendar
|
||||
location *time.Location
|
||||
calCounter *prometheus.CounterVec
|
||||
calSummary *prometheus.SummaryVec
|
||||
calHistogram *prometheus.HistogramVec
|
||||
@@ -26,9 +31,9 @@ var (
|
||||
func init() {
|
||||
loc, err := time.LoadLocation("Europe/Paris")
|
||||
if err != nil {
|
||||
log.Fatalf("unable to load time location: %v", err)
|
||||
zap.S().Fatalf("unable to load time location: %v", err)
|
||||
}
|
||||
cal = calendar.Calendar{Location: loc}
|
||||
location = loc
|
||||
|
||||
calCounter = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "domogeek",
|
||||
@@ -67,25 +72,38 @@ type CalendarDay struct {
|
||||
|
||||
type CalendarHandler struct{}
|
||||
|
||||
func (c *CalendarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now()
|
||||
cd := CalendarDay{
|
||||
Day: now,
|
||||
WorkingDay: cal.IsWorkingDay(now),
|
||||
Ferie: cal.IsHoliday(now),
|
||||
Holiday: cal.IsHoliday(now),
|
||||
Weekday: cal.IsWeekDay(now),
|
||||
}
|
||||
func (c *CalendarHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
||||
cd := calendarDayFor(time.Now())
|
||||
|
||||
content, err := json.Marshal(cd)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
log.Printf("unable to marshall response %v, %v", content, err)
|
||||
zap.S().Errorf("unable to marshall response %v, %v", content, err)
|
||||
} else {
|
||||
_, err = w.Write(content)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
log.Printf("unable to marshall response %v, :%v", content, err)
|
||||
zap.S().Errorf("unable to marshall response %v, :%v", content, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type CurrentDateTime struct {
|
||||
DateTime time.Time `json:"date_time"`
|
||||
}
|
||||
|
||||
type DateTimeHandler struct{}
|
||||
|
||||
func (d *DateTimeHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
||||
content, err := json.Marshal(CurrentDateTime{DateTime: time.Now().In(location)})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
zap.S().Errorf("unable to marshall response %v, %v", content, err)
|
||||
} else {
|
||||
_, err = w.Write(content)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
zap.S().Errorf("unable to marshall response %v, :%v", content, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,13 +111,55 @@ func (c *CalendarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
func main() {
|
||||
var port int
|
||||
var host string
|
||||
var user, pwd string
|
||||
var caldavUrl, caldavPath, caldavSummaryPattern string
|
||||
|
||||
flag.StringVar(&host, "host", "", "host to listen, default all addresses")
|
||||
flag.IntVar(&port, "port", 8080, "port to listen")
|
||||
flag.StringVar(&caldavUrl, "caldav-url", "", "caldav url to use to read holidays events")
|
||||
flag.StringVar(&caldavPath, "caldav-path", "", "caldav path to use to read holidays events")
|
||||
flag.StringVar(&caldavSummaryPattern, "caldav-summary-pattern", "Holidays", "Summary pattern that matches holidays event")
|
||||
flag.StringVar(&user, "caldav-username", "", "Username credential")
|
||||
flag.StringVar(&pwd, "caldav-password", "", "Password credential")
|
||||
flag.Parse()
|
||||
|
||||
logLevel := zap.LevelFlag("log", zap.InfoLevel, "log level")
|
||||
flag.Parse()
|
||||
|
||||
if len(os.Args) <= 1 {
|
||||
flag.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
config := zap.NewDevelopmentConfig()
|
||||
config.Level = zap.NewAtomicLevelAt(*logLevel)
|
||||
lgr, err := config.Build()
|
||||
if err != nil {
|
||||
log.Fatalf("unable to init logger: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = lgr.Sync()
|
||||
}()
|
||||
zap.ReplaceGlobals(lgr)
|
||||
|
||||
urlCaldav, err := url.Parse(caldavUrl)
|
||||
if err != nil {
|
||||
zap.S().Panicf("invalid caldav url '%v': %v", caldavUrl, err)
|
||||
}
|
||||
urlCaldav.User = url.UserPassword(user, pwd)
|
||||
|
||||
cdav, err := calendar.NewCaldav(urlCaldav.String(), caldavPath)
|
||||
if err != nil {
|
||||
zap.S().Fatal("unable to init caldav instance")
|
||||
}
|
||||
cal = calendar.New(location,
|
||||
calendar.WithCaldav(cdav),
|
||||
calendar.WithCaldavPath(caldavPath),
|
||||
calendar.WithCaldavSummaryPattern(caldavSummaryPattern),
|
||||
)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
log.Printf("start server on %s", addr)
|
||||
zap.S().Infof("start server on %s", addr)
|
||||
|
||||
h := promhttp.InstrumentHandlerDuration(
|
||||
calHistogram,
|
||||
@@ -117,9 +177,30 @@ func main() {
|
||||
Check: func(ctx context.Context) error {
|
||||
return nil
|
||||
},
|
||||
}),
|
||||
health.WithChecks(health.Config{
|
||||
Name: "caldav",
|
||||
Timeout: 5 * time.Second,
|
||||
SkipOnErr: false,
|
||||
Check: func(ctx context.Context) error {
|
||||
_, err := cal.IsHolidaysFromCaldav(time.Now())
|
||||
if err != nil {
|
||||
zap.S().Warnf("unable to check caldav connection: %v", err)
|
||||
}
|
||||
return err
|
||||
},
|
||||
))
|
||||
}),
|
||||
)
|
||||
http.Handle("/status", healthz.Handler())
|
||||
http.Handle("/mcp", newMCPHandler())
|
||||
http.Handle("/datetime", &DateTimeHandler{})
|
||||
|
||||
log.Fatal(http.ListenAndServe(addr, nil))
|
||||
signChan := make(chan os.Signal, 1)
|
||||
go func() {
|
||||
zap.S().Fatal(http.ListenAndServe(addr, nil))
|
||||
}()
|
||||
|
||||
signal.Notify(signChan, syscall.SIGTERM)
|
||||
<-signChan
|
||||
zap.S().Info("exit on sigterm")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type mcpDateParams struct {
|
||||
Date string `json:"date" jsonschema:"Date to check, in YYYY-MM-DD format"`
|
||||
}
|
||||
|
||||
type mcpHolidaysParams struct {
|
||||
Year int `json:"year,omitempty" jsonschema:"Year to list French public holidays for (defaults to the current year)"`
|
||||
}
|
||||
|
||||
type mcpHolidaysResult struct {
|
||||
Year int `json:"year"`
|
||||
Holidays []time.Time `json:"holidays"`
|
||||
}
|
||||
|
||||
func calendarDayFor(day time.Time) CalendarDay {
|
||||
calDavHolidays, err := cal.IsHolidaysFromCaldav(day)
|
||||
if err != nil {
|
||||
zap.S().Warnf("unable to read holiday status from caldav: %v", err)
|
||||
calDavHolidays = false
|
||||
}
|
||||
return CalendarDay{
|
||||
Day: day,
|
||||
WorkingDay: cal.IsWorkingDay(day),
|
||||
Ferie: cal.IsHoliday(day),
|
||||
Holiday: calDavHolidays,
|
||||
Weekday: cal.IsWeekDay(day),
|
||||
}
|
||||
}
|
||||
|
||||
func handleCurrentDateTime(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, CurrentDateTime, error) {
|
||||
return nil, CurrentDateTime{DateTime: time.Now().In(location)}, nil
|
||||
}
|
||||
|
||||
func handleCalendarToday(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, CalendarDay, error) {
|
||||
return nil, calendarDayFor(time.Now().In(location)), nil
|
||||
}
|
||||
|
||||
func handleCalendarForDate(_ context.Context, _ *mcp.CallToolRequest, params mcpDateParams) (*mcp.CallToolResult, CalendarDay, error) {
|
||||
day, err := time.ParseInLocation("2006-01-02", params.Date, location)
|
||||
if err != nil {
|
||||
return nil, CalendarDay{}, fmt.Errorf("invalid date %q, expected format YYYY-MM-DD: %w", params.Date, err)
|
||||
}
|
||||
return nil, calendarDayFor(day), nil
|
||||
}
|
||||
|
||||
func handleGetHolidays(_ context.Context, _ *mcp.CallToolRequest, params mcpHolidaysParams) (*mcp.CallToolResult, mcpHolidaysResult, error) {
|
||||
year := params.Year
|
||||
if year == 0 {
|
||||
year = time.Now().In(location).Year()
|
||||
}
|
||||
return nil, mcpHolidaysResult{Year: year, Holidays: *cal.GetHolidays(year)}, nil
|
||||
}
|
||||
|
||||
func newMCPHandler() http.Handler {
|
||||
server := mcp.NewServer(&mcp.Implementation{
|
||||
Name: "domogeek",
|
||||
Version: "1.0.0",
|
||||
}, nil)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "get_current_datetime",
|
||||
Description: "Get the current date and time",
|
||||
}, handleCurrentDateTime)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "get_calendar_today",
|
||||
Description: "Get calendar information for today: working day, French public holiday (ferie), personal holiday (from CalDAV) and weekday",
|
||||
}, handleCalendarToday)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "get_calendar_for_date",
|
||||
Description: "Get calendar information for a given date: working day, French public holiday (ferie), personal holiday (from CalDAV) and weekday",
|
||||
}, handleCalendarForDate)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "get_holidays",
|
||||
Description: "List French public holidays for a given year (defaults to the current year)",
|
||||
}, handleGetHolidays)
|
||||
|
||||
return mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
|
||||
return server
|
||||
}, nil)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func TestMCPEndToEnd(t *testing.T) {
|
||||
withTestCalendar(t)
|
||||
|
||||
srv := httptest.NewServer(newMCPHandler())
|
||||
defer srv.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "1.0.0"}, nil)
|
||||
session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: srv.URL}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("connect failed: %v", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
tools, err := session.ListTools(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("list tools failed: %v", err)
|
||||
}
|
||||
if len(tools.Tools) != 4 {
|
||||
t.Fatalf("got %d tools, want 4", len(tools.Tools))
|
||||
}
|
||||
for _, tool := range tools.Tools {
|
||||
t.Logf("tool: %s - %s", tool.Name, tool.Description)
|
||||
}
|
||||
|
||||
now, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: "get_current_datetime",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call get_current_datetime failed: %v", err)
|
||||
}
|
||||
if now.IsError {
|
||||
t.Fatalf("get_current_datetime returned an error result: %v", now.Content)
|
||||
}
|
||||
t.Logf("current datetime structured content: %+v", now.StructuredContent)
|
||||
|
||||
result, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: "get_calendar_for_date",
|
||||
Arguments: map[string]any{"date": "2020-01-01"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call tool failed: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatalf("tool call returned an error result: %v", result.Content)
|
||||
}
|
||||
t.Logf("structured content: %+v", result.StructuredContent)
|
||||
|
||||
holidays, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: "get_holidays",
|
||||
Arguments: map[string]any{"year": 2020},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call get_holidays failed: %v", err)
|
||||
}
|
||||
t.Logf("holidays structured content: %+v", holidays.StructuredContent)
|
||||
|
||||
badDate, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: "get_calendar_for_date",
|
||||
Arguments: map[string]any{"date": "nope"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call with bad date should be a tool error, not a protocol error: %v", err)
|
||||
}
|
||||
if !badDate.IsError {
|
||||
t.Fatalf("expected IsError=true for an invalid date")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"domogeek/pkg/calendar"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func withTestCalendar(t *testing.T) {
|
||||
t.Helper()
|
||||
previous := cal
|
||||
cal = calendar.New(location)
|
||||
t.Cleanup(func() { cal = previous })
|
||||
}
|
||||
|
||||
func TestCalendarDayFor(t *testing.T) {
|
||||
withTestCalendar(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
day time.Time
|
||||
wantWorkingDay bool
|
||||
wantFerie bool
|
||||
wantWeekday bool
|
||||
}{
|
||||
{
|
||||
name: "public holiday",
|
||||
day: time.Date(2020, time.January, 1, 0, 0, 0, 0, location),
|
||||
wantWorkingDay: false,
|
||||
wantFerie: true,
|
||||
wantWeekday: true,
|
||||
},
|
||||
{
|
||||
name: "regular working day",
|
||||
day: time.Date(2020, time.January, 2, 0, 0, 0, 0, location),
|
||||
wantWorkingDay: true,
|
||||
wantFerie: false,
|
||||
wantWeekday: true,
|
||||
},
|
||||
{
|
||||
name: "week-end",
|
||||
day: time.Date(2020, time.January, 4, 0, 0, 0, 0, location),
|
||||
wantWorkingDay: false,
|
||||
wantFerie: false,
|
||||
wantWeekday: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cd := calendarDayFor(tt.day)
|
||||
if cd.WorkingDay != tt.wantWorkingDay {
|
||||
t.Errorf("WorkingDay = %v, want %v", cd.WorkingDay, tt.wantWorkingDay)
|
||||
}
|
||||
if cd.Ferie != tt.wantFerie {
|
||||
t.Errorf("Ferie = %v, want %v", cd.Ferie, tt.wantFerie)
|
||||
}
|
||||
if cd.Weekday != tt.wantWeekday {
|
||||
t.Errorf("Weekday = %v, want %v", cd.Weekday, tt.wantWeekday)
|
||||
}
|
||||
if !cd.Day.Equal(tt.day) {
|
||||
t.Errorf("Day = %v, want %v", cd.Day, tt.day)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCurrentDateTime(t *testing.T) {
|
||||
_, dt, err := handleCurrentDateTime(context.Background(), nil, struct{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().In(location)
|
||||
if diff := now.Sub(dt.DateTime); diff < -time.Second || diff > time.Second {
|
||||
t.Errorf("DateTime = %v, want close to %v", dt.DateTime, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCalendarToday(t *testing.T) {
|
||||
withTestCalendar(t)
|
||||
|
||||
_, cd, err := handleCalendarToday(context.Background(), nil, struct{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().In(location)
|
||||
wantDay := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), 0, 0, location)
|
||||
gotDay := time.Date(cd.Day.Year(), cd.Day.Month(), cd.Day.Day(), cd.Day.Hour(), cd.Day.Minute(), 0, 0, location)
|
||||
if !gotDay.Equal(wantDay) {
|
||||
t.Errorf("Day = %v, want close to %v", cd.Day, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCalendarForDate(t *testing.T) {
|
||||
withTestCalendar(t)
|
||||
|
||||
t.Run("valid date", func(t *testing.T) {
|
||||
_, cd, err := handleCalendarForDate(context.Background(), nil, mcpDateParams{Date: "2020-01-01"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !cd.Ferie {
|
||||
t.Errorf("expected 2020-01-01 to be a ferie day")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid date", func(t *testing.T) {
|
||||
_, _, err := handleCalendarForDate(context.Background(), nil, mcpDateParams{Date: "not-a-date"})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an invalid date, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleGetHolidays(t *testing.T) {
|
||||
withTestCalendar(t)
|
||||
|
||||
t.Run("explicit year", func(t *testing.T) {
|
||||
_, result, err := handleGetHolidays(context.Background(), nil, mcpHolidaysParams{Year: 2020})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Year != 2020 {
|
||||
t.Errorf("Year = %d, want 2020", result.Year)
|
||||
}
|
||||
if len(result.Holidays) != 10 {
|
||||
t.Errorf("got %d holidays, want 10", len(result.Holidays))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("defaults to current year", func(t *testing.T) {
|
||||
_, result, err := handleGetHolidays(context.Background(), nil, mcpHolidaysParams{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Year != time.Now().In(location).Year() {
|
||||
t.Errorf("Year = %d, want current year %d", result.Year, time.Now().In(location).Year())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,22 +1,35 @@
|
||||
module domogeek
|
||||
|
||||
go 1.18
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/avast/retry-go v2.7.0+incompatible
|
||||
github.com/dolanor/caldav-go v0.2.1
|
||||
github.com/hellofresh/health-go/v4 v4.5.0
|
||||
github.com/modelcontextprotocol/go-sdk v1.7.0
|
||||
github.com/prometheus/client_golang v1.12.1
|
||||
go.uber.org/zap v1.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
|
||||
github.com/prometheus/client_model v0.2.0 // indirect
|
||||
github.com/prometheus/common v0.32.1 // indirect
|
||||
github.com/prometheus/procfs v0.7.3 // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
go.opentelemetry.io/otel v1.0.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.0.0 // indirect
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.26.0 // indirect
|
||||
)
|
||||
|
||||
@@ -39,6 +39,10 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||
github.com/avast/retry-go v2.7.0+incompatible h1:XaGnzl7gESAideSjr+I8Hki/JBi+Yb9baHlMRPeSC84=
|
||||
github.com/avast/retry-go v2.7.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
|
||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -62,6 +66,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dolanor/caldav-go v0.2.1 h1:wbARF+WKIryMOApYdX6CnFKY25Kz3ChPerfFc/R3Txk=
|
||||
github.com/dolanor/caldav-go v0.2.1/go.mod h1:0A9uEq2TN7U1eNh11hHAZ0FA7jyuFSh5hXeaoFPC0fc=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
@@ -108,6 +114,8 @@ github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/V
|
||||
github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw=
|
||||
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
@@ -149,9 +157,12 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
@@ -226,9 +237,11 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
@@ -244,6 +257,8 @@ github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
|
||||
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
@@ -263,6 +278,7 @@ github.com/onsi/gomega v1.15.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+t
|
||||
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -296,6 +312,10 @@ github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
@@ -320,11 +340,14 @@ github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhV
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
|
||||
github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
go.mongodb.org/mongo-driver v1.7.2/go.mod h1:Q4oFMbo1+MSNqICAdYMlC/zSTrwCogR4R8NzkI+yfU8=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
@@ -340,13 +363,21 @@ go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
|
||||
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
||||
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
||||
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
|
||||
go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
|
||||
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
|
||||
@@ -391,6 +422,7 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB
|
||||
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -424,6 +456,7 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
@@ -432,6 +465,8 @@ golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4Iltr
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -443,6 +478,9 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -489,11 +527,14 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 h1:XfKQ4OlFl8okEOr5UvAqFRVj8pY/4yfcXrddB8qAbU0=
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -508,6 +549,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
@@ -558,12 +601,14 @@ golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc
|
||||
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
@@ -645,6 +690,7 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
@@ -656,9 +702,11 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/avast/retry-go"
|
||||
"github.com/dolanor/caldav-go/caldav"
|
||||
"github.com/dolanor/caldav-go/caldav/entities"
|
||||
"github.com/dolanor/caldav-go/icalendar/components"
|
||||
"go.uber.org/zap"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Caldav interface {
|
||||
QueryEvents(path string, query *entities.CalendarQuery) (events []*components.Event, oerr error)
|
||||
}
|
||||
|
||||
type Calendar struct {
|
||||
Location *time.Location
|
||||
cdav Caldav
|
||||
caldavPath string
|
||||
caldavSummaryPattern string
|
||||
}
|
||||
|
||||
func NewCaldav(caldavUrl, caldavPath string) (Caldav, error) {
|
||||
// create a reference to your CalDAV-compliant server
|
||||
server, _ := caldav.NewServer(caldavUrl)
|
||||
// create a CalDAV client to speak to the server
|
||||
var client = caldav.NewClient(server, http.DefaultClient)
|
||||
err := retry.Do(
|
||||
func() error {
|
||||
// start executing requests!
|
||||
err := client.ValidateServer(caldavPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad caldav configuration, unable to validate connexion: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
retry.OnRetry(
|
||||
func(n uint, err error) {
|
||||
zap.S().Errorf("unable to validate caldav connection on retry %d: %v", n, err)
|
||||
},
|
||||
),
|
||||
retry.Attempts(1000),
|
||||
retry.DelayType(retry.BackOffDelay),
|
||||
retry.MaxDelay(24*time.Hour),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to validate caldav connection: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
type Option func(calendar *Calendar)
|
||||
|
||||
func WithCaldav(cdav Caldav) Option {
|
||||
return func(calendar *Calendar) {
|
||||
calendar.cdav = cdav
|
||||
}
|
||||
}
|
||||
|
||||
func WithCaldavSummaryPattern(caldavSummaryPattern string) Option {
|
||||
return func(calendar *Calendar) {
|
||||
calendar.caldavSummaryPattern = caldavSummaryPattern
|
||||
}
|
||||
}
|
||||
|
||||
func WithCaldavPath(caldavPath string) Option {
|
||||
return func(calendar *Calendar) {
|
||||
calendar.caldavPath = caldavPath
|
||||
}
|
||||
}
|
||||
|
||||
func New(location *time.Location, opts ...Option) *Calendar {
|
||||
c := &Calendar{
|
||||
location,
|
||||
nil,
|
||||
"",
|
||||
"",
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (cal *Calendar) GetEasterDay(year int) time.Time {
|
||||
g := float64(year % 19.0)
|
||||
c := math.Floor(float64(year) / 100.0)
|
||||
c4 := math.Floor(c / 4.0)
|
||||
h := float64(int(19.0*g+c-c4-math.Floor((8.0*c+13)/25)+15) % 30.0)
|
||||
k := math.Floor(h / 28.0)
|
||||
i := (k*math.Floor(29./(h+1.))*math.Floor((21.-g)/11.)-1.)*k + h
|
||||
|
||||
// jour de Pâques (0=dimanche, 1=lundi....)
|
||||
dayWeek := int(math.Floor(float64(year)/4.)+float64(year)+i+2+c4-c) % 7
|
||||
|
||||
// Jour de Pâques en jours enpartant de 1 = 1er mars
|
||||
presJour := int(28 + int(i) - dayWeek)
|
||||
|
||||
// mois (0 = janvier, ... 2 = mars, 3 = avril)
|
||||
month := 2
|
||||
if presJour > 31 {
|
||||
month = 3
|
||||
}
|
||||
|
||||
// Mois dans l'année
|
||||
month += 1
|
||||
|
||||
// jour du mois
|
||||
day := presJour - 31
|
||||
if month == 2 {
|
||||
day = presJour
|
||||
}
|
||||
|
||||
return time.Date(year, 3, 31, 0, 0, 0, 0, cal.Location).AddDate(0, 0, day)
|
||||
}
|
||||
|
||||
func (cal *Calendar) GetHolidays(year int) *[]time.Time {
|
||||
|
||||
// Calcul du jour de pâques
|
||||
paques := cal.GetEasterDay(year)
|
||||
|
||||
joursFeries := []time.Time{
|
||||
// Jour de l'an
|
||||
time.Date(year, time.January, 1, 0, 0, 0, 0, cal.Location),
|
||||
// Easter
|
||||
paques.AddDate(0, 0, 1),
|
||||
// 1 mai
|
||||
time.Date(year, time.May, 1, 0, 0, 0, 0, cal.Location),
|
||||
// 8 mai
|
||||
time.Date(year, time.May, 8, 0, 0, 0, 0, cal.Location),
|
||||
// Ascension
|
||||
paques.AddDate(0, 0, 39),
|
||||
// 14 juillet
|
||||
time.Date(year, time.July, 14, 0, 0, 0, 0, cal.Location),
|
||||
// 15 aout
|
||||
time.Date(year, time.August, 15, 0, 0, 0, 0, cal.Location),
|
||||
// Toussaint
|
||||
time.Date(year, time.November, 1, 0, 0, 0, 0, cal.Location),
|
||||
// 11 novembre
|
||||
time.Date(year, time.November, 11, 0, 0, 0, 0, cal.Location),
|
||||
// noël
|
||||
time.Date(year, time.December, 25, 0, 0, 0, 0, cal.Location),
|
||||
}
|
||||
|
||||
return &joursFeries
|
||||
}
|
||||
|
||||
func (cal *Calendar) GetHolidaysSet(year int) map[time.Time]bool {
|
||||
holidays := cal.GetHolidays(year)
|
||||
result := make(map[time.Time]bool, len(*holidays))
|
||||
for _, h := range *holidays {
|
||||
result[h] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (cal *Calendar) IsHoliday(date time.Time) bool {
|
||||
h := cal.GetHolidaysSet(date.Year())
|
||||
d := date.In(cal.Location)
|
||||
day := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, cal.Location)
|
||||
caldavHolidays, err := cal.IsHolidaysFromCaldav(day)
|
||||
if err != nil {
|
||||
zap.S().Errorf("unable to check holidays from caldav: %v", err)
|
||||
}
|
||||
return h[day] || caldavHolidays
|
||||
}
|
||||
|
||||
func (cal *Calendar) IsWorkingDay(date time.Time) bool {
|
||||
return !cal.IsHoliday(date) && date.Weekday() >= time.Monday && date.Weekday() <= time.Friday
|
||||
}
|
||||
|
||||
func (cal *Calendar) IsWorkingDayToday() bool {
|
||||
return cal.IsWorkingDay(time.Now())
|
||||
}
|
||||
|
||||
func (cal *Calendar) IsWeekDay(day time.Time) bool {
|
||||
return day.Weekday() >= time.Monday && day.Weekday() <= time.Friday
|
||||
}
|
||||
|
||||
func (cal *Calendar) IsHolidaysFromCaldav(day time.Time) (bool, error) {
|
||||
if cal.cdav == nil {
|
||||
return false, nil
|
||||
}
|
||||
query, err := entities.NewEventRangeQuery(day.UTC(), day.UTC().Add(23*time.Hour+59*time.Minute))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unable to build events range query: %v", err)
|
||||
}
|
||||
events, err := cal.cdav.QueryEvents(cal.caldavPath, query)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unable list events from caldav: %v", err)
|
||||
}
|
||||
|
||||
for _, evt := range events {
|
||||
if strings.Contains(evt.Summary, cal.caldavSummaryPattern) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"github.com/dolanor/caldav-go/caldav/entities"
|
||||
"github.com/dolanor/caldav-go/icalendar/components"
|
||||
"github.com/dolanor/caldav-go/icalendar/values"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -18,7 +21,7 @@ func TestCalendar_GetEasterDay(t *testing.T) {
|
||||
time.Date(2021, time.April, 4, 0, 0, 0, 0, loc),
|
||||
}
|
||||
|
||||
c := Calendar{loc}
|
||||
c := New(loc)
|
||||
|
||||
for _, d := range easterDays {
|
||||
easter := c.GetEasterDay(d.Year())
|
||||
@@ -37,7 +40,7 @@ func TestCalendar_GetHolidays(t *testing.T) {
|
||||
|
||||
expectedHolidays := map[time.Time]bool{
|
||||
time.Date(2020, time.January, 1, 0, 0, 0, 0, loc): true,
|
||||
time.Date(2020, time.April, 12, 0, 0, 0, 0, loc): true,
|
||||
time.Date(2020, time.April, 13, 0, 0, 0, 0, loc): true,
|
||||
time.Date(2020, time.May, 1, 0, 0, 0, 0, loc): true,
|
||||
time.Date(2020, time.May, 8, 0, 0, 0, 0, loc): true,
|
||||
time.Date(2020, time.May, 21, 0, 0, 0, 0, loc): true,
|
||||
@@ -48,7 +51,7 @@ func TestCalendar_GetHolidays(t *testing.T) {
|
||||
time.Date(2020, time.December, 25, 0, 0, 0, 0, loc): true,
|
||||
}
|
||||
|
||||
c := Calendar{loc}
|
||||
c := New(loc)
|
||||
holidays := c.GetHolidays(2020)
|
||||
if len(*holidays) != len(expectedHolidays) {
|
||||
t.Errorf("bad number of holidays, %d but %d are expected", len(*holidays), len(expectedHolidays))
|
||||
@@ -69,7 +72,7 @@ func TestCalendar_GetHolidaysSet(t *testing.T) {
|
||||
|
||||
expectedHolidays := []time.Time{
|
||||
time.Date(2020, time.January, 1, 0, 0, 0, 0, loc),
|
||||
time.Date(2020, time.April, 12, 0, 0, 0, 0, loc),
|
||||
time.Date(2020, time.April, 13, 0, 0, 0, 0, loc),
|
||||
time.Date(2020, time.May, 1, 0, 0, 0, 0, loc),
|
||||
time.Date(2020, time.May, 8, 0, 0, 0, 0, loc),
|
||||
time.Date(2020, time.May, 21, 0, 0, 0, 0, loc),
|
||||
@@ -80,13 +83,13 @@ func TestCalendar_GetHolidaysSet(t *testing.T) {
|
||||
time.Date(2020, time.December, 25, 0, 0, 0, 0, loc),
|
||||
}
|
||||
|
||||
c := Calendar{loc}
|
||||
c := New(loc)
|
||||
holidays := c.GetHolidaysSet(2020)
|
||||
if len(*holidays) != len(expectedHolidays) {
|
||||
t.Errorf("bad number of holidays, %d but %d are expected", len(*holidays), len(expectedHolidays))
|
||||
if len(holidays) != len(expectedHolidays) {
|
||||
t.Errorf("bad number of holidays, %d but %d are expected", len(holidays), len(expectedHolidays))
|
||||
}
|
||||
for _, h := range expectedHolidays {
|
||||
if !(*holidays)[h] {
|
||||
if !(holidays)[h] {
|
||||
t.Errorf("%v is not a holiday", h)
|
||||
}
|
||||
}
|
||||
@@ -101,7 +104,7 @@ func TestCalendar_IsHolidays(t *testing.T) {
|
||||
|
||||
expectedHolidays := []time.Time{
|
||||
time.Date(2020, time.January, 1, 0, 0, 0, 0, loc),
|
||||
time.Date(2020, time.April, 12, 0, 0, 0, 0, loc),
|
||||
time.Date(2020, time.April, 13, 0, 0, 0, 0, loc),
|
||||
time.Date(2020, time.May, 1, 0, 0, 0, 0, loc),
|
||||
time.Date(2020, time.May, 8, 0, 0, 0, 0, loc),
|
||||
time.Date(2020, time.May, 21, 0, 0, 0, 0, loc),
|
||||
@@ -112,10 +115,10 @@ func TestCalendar_IsHolidays(t *testing.T) {
|
||||
time.Date(2020, time.December, 25, 0, 0, 0, 0, loc),
|
||||
}
|
||||
|
||||
c := Calendar{loc}
|
||||
c := New(loc)
|
||||
holidays := c.GetHolidaysSet(2020)
|
||||
if len(*holidays) != len(expectedHolidays) {
|
||||
t.Errorf("bad number of holidays, %d but %d are expected", len(*holidays), len(expectedHolidays))
|
||||
if len(holidays) != len(expectedHolidays) {
|
||||
t.Errorf("bad number of holidays, %d but %d are expected", len(holidays), len(expectedHolidays))
|
||||
}
|
||||
for _, h := range expectedHolidays {
|
||||
if !c.IsHoliday(h) {
|
||||
@@ -133,7 +136,7 @@ func TestCalendar_IsWorkingDay(t *testing.T) {
|
||||
t.Errorf("unable to load time location: %v", err)
|
||||
t.Fail()
|
||||
}
|
||||
c := Calendar{loc}
|
||||
c := New(loc)
|
||||
|
||||
if c.IsWorkingDay(time.Date(2019, time.January, 01, 0, 0, 0, 0, loc)) {
|
||||
t.Error("1st january is not a working day")
|
||||
@@ -164,3 +167,114 @@ func TestCalendar_IsWorkingDay(t *testing.T) {
|
||||
t.Error("Sunday should not be a working day")
|
||||
}
|
||||
}
|
||||
|
||||
type MockCaldav struct {
|
||||
events []*components.Event
|
||||
}
|
||||
|
||||
func (m *MockCaldav) QueryEvents(_ string, _ *entities.CalendarQuery) ([]*components.Event, error) {
|
||||
return m.events, nil
|
||||
}
|
||||
|
||||
func TestCalendar_IsHolidaysFromCaldav(t *testing.T) {
|
||||
loc, err := time.LoadLocation("Europe/Paris")
|
||||
if err != nil {
|
||||
t.Errorf("unable to load time location: %v", err)
|
||||
t.Fail()
|
||||
}
|
||||
type fields struct {
|
||||
Location *time.Location
|
||||
cdav *MockCaldav
|
||||
caldavPath string
|
||||
caldavSummaryPattern string
|
||||
}
|
||||
type args struct {
|
||||
day time.Time
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
want bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Holidays in events",
|
||||
fields: fields{
|
||||
Location: loc,
|
||||
cdav: &MockCaldav{
|
||||
events: []*components.Event{
|
||||
{
|
||||
UID: "1",
|
||||
DateStart: values.NewDateTime(time.Date(2022, time.April, 16, 0, 0, 0, 0, loc)),
|
||||
DateEnd: values.NewDateTime(time.Date(2022, time.April, 17, 0, 0, 0, 0, loc)),
|
||||
Summary: "Holidays",
|
||||
},
|
||||
},
|
||||
},
|
||||
caldavPath: "my_calendar/",
|
||||
caldavSummaryPattern: "Holidays",
|
||||
},
|
||||
args: args{
|
||||
day: time.Date(2022, time.April, 16, 0, 0, 0, 0, loc),
|
||||
},
|
||||
want: true,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Not Holidays in events",
|
||||
fields: fields{
|
||||
Location: loc,
|
||||
cdav: &MockCaldav{
|
||||
events: []*components.Event{
|
||||
{
|
||||
UID: "1",
|
||||
DateStart: values.NewDateTime(time.Date(2022, time.April, 16, 0, 0, 0, 0, loc)),
|
||||
DateEnd: values.NewDateTime(time.Date(2022, time.April, 17, 0, 0, 0, 0, loc)),
|
||||
Summary: "Another event",
|
||||
},
|
||||
},
|
||||
},
|
||||
caldavPath: "my_calendar/",
|
||||
caldavSummaryPattern: "Holidays",
|
||||
},
|
||||
args: args{
|
||||
day: time.Date(2022, time.April, 16, 0, 0, 0, 0, loc),
|
||||
},
|
||||
want: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "No events",
|
||||
fields: fields{
|
||||
Location: loc,
|
||||
cdav: &MockCaldav{},
|
||||
caldavPath: "my_calendar/",
|
||||
caldavSummaryPattern: "Holidays",
|
||||
},
|
||||
args: args{
|
||||
day: time.Date(2022, time.April, 15, 0, 0, 0, 0, loc),
|
||||
},
|
||||
want: false,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cal := New(
|
||||
loc,
|
||||
WithCaldav(tt.fields.cdav),
|
||||
WithCaldavPath(tt.fields.caldavPath),
|
||||
WithCaldavSummaryPattern(tt.fields.caldavSummaryPattern),
|
||||
)
|
||||
got, err := cal.IsHolidaysFromCaldav(tt.args.day)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("IsHolidaysFromCaldav() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("IsHolidaysFromCaldav() got = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, build with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736
|
||||
.glide/
|
||||
|
||||
# dep
|
||||
vendor/
|
||||
Gopkg.lock
|
||||
|
||||
# cover
|
||||
coverage.txt
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# {{ .Name }}
|
||||
|
||||
[](https://github.com/avast/retry-go/releases/latest)
|
||||
[](LICENSE.md)
|
||||
[](https://travis-ci.org/avast/retry-go)
|
||||
[](https://ci.appveyor.com/project/JaSei/retry-go)
|
||||
[](https://goreportcard.com/report/github.com/avast/retry-go)
|
||||
[](http://godoc.org/github.com/avast/retry-go)
|
||||
[](https://codecov.io/github/avast/retry-go?branch=master)
|
||||
[](https://sourcegraph.com/github.com/avast/retry-go?badge)
|
||||
|
||||
{{ .EmitSynopsis }}
|
||||
|
||||
{{ .EmitUsage }}
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are very much welcome.
|
||||
|
||||
### Makefile
|
||||
|
||||
Makefile provides several handy rules, like README.md `generator` , `setup` for prepare build/dev environment, `test`, `cover`, etc...
|
||||
|
||||
Try `make help` for more information.
|
||||
|
||||
### Before pull request
|
||||
|
||||
please try:
|
||||
* run tests (`make test`)
|
||||
* run linter (`make lint`)
|
||||
* if your IDE don't automaticaly do `go fmt`, run `go fmt` (`make fmt`)
|
||||
|
||||
### README
|
||||
|
||||
README.md are generate from template [.godocdown.tmpl](.godocdown.tmpl) and code documentation via [godocdown](https://github.com/robertkrimen/godocdown).
|
||||
|
||||
Never edit README.md direct, because your change will be lost.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
language: go
|
||||
|
||||
go:
|
||||
- 1.7
|
||||
- 1.8
|
||||
- 1.9
|
||||
- "1.10"
|
||||
- 1.11
|
||||
- 1.12
|
||||
- 1.13
|
||||
- 1.14
|
||||
- 1.15
|
||||
|
||||
install:
|
||||
- make setup
|
||||
|
||||
script:
|
||||
- make ci
|
||||
|
||||
after_success:
|
||||
- bash <(curl -s https://codecov.io/bash)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
[[constraint]]
|
||||
name = "github.com/stretchr/testify"
|
||||
version = "1.1.4"
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 Avast
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
SOURCE_FILES?=$$(go list ./... | grep -v /vendor/)
|
||||
TEST_PATTERN?=.
|
||||
TEST_OPTIONS?=
|
||||
DEP?=$$(which dep)
|
||||
VERSION?=$$(cat VERSION)
|
||||
LINTER?=$$(which golangci-lint)
|
||||
LINTER_VERSION=1.15.0
|
||||
|
||||
ifeq ($(OS),Windows_NT)
|
||||
DEP_VERS=dep-windows-amd64
|
||||
LINTER_FILE=golangci-lint-$(LINTER_VERSION)-windows-amd64.zip
|
||||
LINTER_UNPACK= >| app.zip; unzip -j app.zip -d $$GOPATH/bin; rm app.zip
|
||||
else ifeq ($(OS), Darwin)
|
||||
LINTER_FILE=golangci-lint-$(LINTER_VERSION)-darwin-amd64.tar.gz
|
||||
LINTER_UNPACK= | tar xzf - -C $$GOPATH/bin --wildcards --strip 1 "**/golangci-lint"
|
||||
else
|
||||
DEP_VERS=dep-linux-amd64
|
||||
LINTER_FILE=golangci-lint-$(LINTER_VERSION)-linux-amd64.tar.gz
|
||||
LINTER_UNPACK= | tar xzf - -C $$GOPATH/bin --wildcards --strip 1 "**/golangci-lint"
|
||||
endif
|
||||
|
||||
setup:
|
||||
go get -u github.com/pierrre/gotestcover
|
||||
go get -u golang.org/x/tools/cmd/cover
|
||||
go get -u github.com/robertkrimen/godocdown/godocdown
|
||||
@if [ "$(LINTER)" = "" ]; then\
|
||||
curl -L https://github.com/golangci/golangci-lint/releases/download/v$(LINTER_VERSION)/$(LINTER_FILE) $(LINTER_UNPACK) ;\
|
||||
chmod +x $$GOPATH/bin/golangci-lint;\
|
||||
fi
|
||||
@if [ "$(DEP)" = "" ]; then\
|
||||
curl -L https://github.com/golang/dep/releases/download/v0.3.1/$(DEP_VERS) >| $$GOPATH/bin/dep;\
|
||||
chmod +x $$GOPATH/bin/dep;\
|
||||
fi
|
||||
dep ensure
|
||||
|
||||
generate: ## Generate README.md
|
||||
godocdown >| README.md
|
||||
|
||||
test: generate test_and_cover_report lint
|
||||
|
||||
test_and_cover_report:
|
||||
gotestcover $(TEST_OPTIONS) -covermode=atomic -coverprofile=coverage.txt $(SOURCE_FILES) -run $(TEST_PATTERN) -timeout=2m
|
||||
|
||||
cover: test ## Run all the tests and opens the coverage report
|
||||
go tool cover -html=coverage.txt
|
||||
|
||||
fmt: ## gofmt and goimports all go files
|
||||
find . -name '*.go' -not -wholename './vendor/*' | while read -r file; do gofmt -w -s "$$file"; goimports -w "$$file"; done
|
||||
|
||||
lint: ## Run all the linters
|
||||
golangci-lint run
|
||||
|
||||
ci: test_and_cover_report ## Run all the tests but no linters - use https://golangci.com integration instead
|
||||
|
||||
build:
|
||||
go build
|
||||
|
||||
release: ## Release new version
|
||||
git tag | grep -q $(VERSION) && echo This version was released! Increase VERSION! || git tag $(VERSION) && git push origin $(VERSION) && git tag v$(VERSION) && git push origin v$(VERSION)
|
||||
|
||||
# Absolutely awesome: http://marmelab.com/blog/2016/02/29/auto-documented-makefile.html
|
||||
help:
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.DEFAULT_GOAL := build
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
# retry
|
||||
|
||||
[](https://github.com/avast/retry-go/releases/latest)
|
||||
[](LICENSE.md)
|
||||
[](https://travis-ci.org/avast/retry-go)
|
||||
[](https://ci.appveyor.com/project/JaSei/retry-go)
|
||||
[](https://goreportcard.com/report/github.com/avast/retry-go)
|
||||
[](http://godoc.org/github.com/avast/retry-go)
|
||||
[](https://codecov.io/github/avast/retry-go?branch=master)
|
||||
[](https://sourcegraph.com/github.com/avast/retry-go?badge)
|
||||
|
||||
Simple library for retry mechanism
|
||||
|
||||
slightly inspired by
|
||||
[Try::Tiny::Retry](https://metacpan.org/pod/Try::Tiny::Retry)
|
||||
|
||||
|
||||
### SYNOPSIS
|
||||
|
||||
http get with retry:
|
||||
|
||||
url := "http://example.com"
|
||||
var body []byte
|
||||
|
||||
err := retry.Do(
|
||||
func() error {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
fmt.Println(body)
|
||||
|
||||
[next examples](https://github.com/avast/retry-go/tree/master/examples)
|
||||
|
||||
|
||||
### SEE ALSO
|
||||
|
||||
* [giantswarm/retry-go](https://github.com/giantswarm/retry-go) - slightly
|
||||
complicated interface.
|
||||
|
||||
* [sethgrid/pester](https://github.com/sethgrid/pester) - only http retry for
|
||||
http calls with retries and backoff
|
||||
|
||||
* [cenkalti/backoff](https://github.com/cenkalti/backoff) - Go port of the
|
||||
exponential backoff algorithm from Google's HTTP Client Library for Java. Really
|
||||
complicated interface.
|
||||
|
||||
* [rafaeljesus/retry-go](https://github.com/rafaeljesus/retry-go) - looks good,
|
||||
slightly similar as this package, don't have 'simple' `Retry` method
|
||||
|
||||
* [matryer/try](https://github.com/matryer/try) - very popular package,
|
||||
nonintuitive interface (for me)
|
||||
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
1.0.2 -> 2.0.0
|
||||
|
||||
* argument of `retry.Delay` is final delay (no multiplication by `retry.Units`
|
||||
anymore)
|
||||
|
||||
* function `retry.Units` are removed
|
||||
|
||||
* [more about this breaking change](https://github.com/avast/retry-go/issues/7)
|
||||
|
||||
0.3.0 -> 1.0.0
|
||||
|
||||
* `retry.Retry` function are changed to `retry.Do` function
|
||||
|
||||
* `retry.RetryCustom` (OnRetry) and `retry.RetryCustomWithOpts` functions are
|
||||
now implement via functions produces Options (aka `retry.OnRetry`)
|
||||
|
||||
## Usage
|
||||
|
||||
```go
|
||||
var (
|
||||
DefaultAttempts = uint(10)
|
||||
DefaultDelay = 100 * time.Millisecond
|
||||
DefaultMaxJitter = 100 * time.Millisecond
|
||||
DefaultOnRetry = func(n uint, err error) {}
|
||||
DefaultRetryIf = IsRecoverable
|
||||
DefaultDelayType = CombineDelay(BackOffDelay, RandomDelay)
|
||||
DefaultLastErrorOnly = false
|
||||
DefaultContext = context.Background()
|
||||
)
|
||||
```
|
||||
|
||||
#### func BackOffDelay
|
||||
|
||||
```go
|
||||
func BackOffDelay(n uint, config *Config) time.Duration
|
||||
```
|
||||
BackOffDelay is a DelayType which increases delay between consecutive retries
|
||||
|
||||
#### func Do
|
||||
|
||||
```go
|
||||
func Do(retryableFunc RetryableFunc, opts ...Option) error
|
||||
```
|
||||
|
||||
#### func FixedDelay
|
||||
|
||||
```go
|
||||
func FixedDelay(_ uint, config *Config) time.Duration
|
||||
```
|
||||
FixedDelay is a DelayType which keeps delay the same through all iterations
|
||||
|
||||
#### func IsRecoverable
|
||||
|
||||
```go
|
||||
func IsRecoverable(err error) bool
|
||||
```
|
||||
IsRecoverable checks if error is an instance of `unrecoverableError`
|
||||
|
||||
#### func RandomDelay
|
||||
|
||||
```go
|
||||
func RandomDelay(_ uint, config *Config) time.Duration
|
||||
```
|
||||
RandomDelay is a DelayType which picks a random delay up to config.maxJitter
|
||||
|
||||
#### func Unrecoverable
|
||||
|
||||
```go
|
||||
func Unrecoverable(err error) error
|
||||
```
|
||||
Unrecoverable wraps an error in `unrecoverableError` struct
|
||||
|
||||
#### type Config
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
#### type DelayTypeFunc
|
||||
|
||||
```go
|
||||
type DelayTypeFunc func(n uint, config *Config) time.Duration
|
||||
```
|
||||
|
||||
|
||||
#### func CombineDelay
|
||||
|
||||
```go
|
||||
func CombineDelay(delays ...DelayTypeFunc) DelayTypeFunc
|
||||
```
|
||||
CombineDelay is a DelayType the combines all of the specified delays into a new
|
||||
DelayTypeFunc
|
||||
|
||||
#### type Error
|
||||
|
||||
```go
|
||||
type Error []error
|
||||
```
|
||||
|
||||
Error type represents list of errors in retry
|
||||
|
||||
#### func (Error) Error
|
||||
|
||||
```go
|
||||
func (e Error) Error() string
|
||||
```
|
||||
Error method return string representation of Error It is an implementation of
|
||||
error interface
|
||||
|
||||
#### func (Error) WrappedErrors
|
||||
|
||||
```go
|
||||
func (e Error) WrappedErrors() []error
|
||||
```
|
||||
WrappedErrors returns the list of errors that this Error is wrapping. It is an
|
||||
implementation of the `errwrap.Wrapper` interface in package
|
||||
[errwrap](https://github.com/hashicorp/errwrap) so that `retry.Error` can be
|
||||
used with that library.
|
||||
|
||||
#### type OnRetryFunc
|
||||
|
||||
```go
|
||||
type OnRetryFunc func(n uint, err error)
|
||||
```
|
||||
|
||||
Function signature of OnRetry function n = count of attempts
|
||||
|
||||
#### type Option
|
||||
|
||||
```go
|
||||
type Option func(*Config)
|
||||
```
|
||||
|
||||
Option represents an option for retry.
|
||||
|
||||
#### func Attempts
|
||||
|
||||
```go
|
||||
func Attempts(attempts uint) Option
|
||||
```
|
||||
Attempts set count of retry default is 10
|
||||
|
||||
#### func Context
|
||||
|
||||
```go
|
||||
func Context(ctx context.Context) Option
|
||||
```
|
||||
Context allow to set context of retry default are Background context
|
||||
|
||||
example of immediately cancellation (maybe it isn't the best example, but it describes behavior enough; I hope)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
retry.Do(
|
||||
func() error {
|
||||
...
|
||||
},
|
||||
retry.Context(ctx),
|
||||
)
|
||||
|
||||
#### func Delay
|
||||
|
||||
```go
|
||||
func Delay(delay time.Duration) Option
|
||||
```
|
||||
Delay set delay between retry default is 100ms
|
||||
|
||||
#### func DelayType
|
||||
|
||||
```go
|
||||
func DelayType(delayType DelayTypeFunc) Option
|
||||
```
|
||||
DelayType set type of the delay between retries default is BackOff
|
||||
|
||||
#### func LastErrorOnly
|
||||
|
||||
```go
|
||||
func LastErrorOnly(lastErrorOnly bool) Option
|
||||
```
|
||||
return the direct last error that came from the retried function default is
|
||||
false (return wrapped errors with everything)
|
||||
|
||||
#### func MaxDelay
|
||||
|
||||
```go
|
||||
func MaxDelay(maxDelay time.Duration) Option
|
||||
```
|
||||
MaxDelay set maximum delay between retry does not apply by default
|
||||
|
||||
#### func MaxJitter
|
||||
|
||||
```go
|
||||
func MaxJitter(maxJitter time.Duration) Option
|
||||
```
|
||||
MaxJitter sets the maximum random Jitter between retries for RandomDelay
|
||||
|
||||
#### func OnRetry
|
||||
|
||||
```go
|
||||
func OnRetry(onRetry OnRetryFunc) Option
|
||||
```
|
||||
OnRetry function callback are called each retry
|
||||
|
||||
log each retry example:
|
||||
|
||||
retry.Do(
|
||||
func() error {
|
||||
return errors.New("some error")
|
||||
},
|
||||
retry.OnRetry(func(n uint, err error) {
|
||||
log.Printf("#%d: %s\n", n, err)
|
||||
}),
|
||||
)
|
||||
|
||||
#### func RetryIf
|
||||
|
||||
```go
|
||||
func RetryIf(retryIf RetryIfFunc) Option
|
||||
```
|
||||
RetryIf controls whether a retry should be attempted after an error (assuming
|
||||
there are any retry attempts remaining)
|
||||
|
||||
skip retry if special error example:
|
||||
|
||||
retry.Do(
|
||||
func() error {
|
||||
return errors.New("special error")
|
||||
},
|
||||
retry.RetryIf(func(err error) bool {
|
||||
if err.Error() == "special error" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
)
|
||||
|
||||
By default RetryIf stops execution if the error is wrapped using
|
||||
`retry.Unrecoverable`, so above example may also be shortened to:
|
||||
|
||||
retry.Do(
|
||||
func() error {
|
||||
return retry.Unrecoverable(errors.New("special error"))
|
||||
}
|
||||
)
|
||||
|
||||
#### type RetryIfFunc
|
||||
|
||||
```go
|
||||
type RetryIfFunc func(error) bool
|
||||
```
|
||||
|
||||
Function signature of retry if function
|
||||
|
||||
#### type RetryableFunc
|
||||
|
||||
```go
|
||||
type RetryableFunc func() error
|
||||
```
|
||||
|
||||
Function signature of retryable function
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are very much welcome.
|
||||
|
||||
### Makefile
|
||||
|
||||
Makefile provides several handy rules, like README.md `generator` , `setup` for prepare build/dev environment, `test`, `cover`, etc...
|
||||
|
||||
Try `make help` for more information.
|
||||
|
||||
### Before pull request
|
||||
|
||||
please try:
|
||||
* run tests (`make test`)
|
||||
* run linter (`make lint`)
|
||||
* if your IDE don't automaticaly do `go fmt`, run `go fmt` (`make fmt`)
|
||||
|
||||
### README
|
||||
|
||||
README.md are generate from template [.godocdown.tmpl](.godocdown.tmpl) and code documentation via [godocdown](https://github.com/robertkrimen/godocdown).
|
||||
|
||||
Never edit README.md direct, because your change will be lost.
|
||||
+1
@@ -0,0 +1 @@
|
||||
2.7.0
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
version: "{build}"
|
||||
|
||||
clone_folder: c:\Users\appveyor\go\src\github.com\avast\retry-go
|
||||
|
||||
#os: Windows Server 2012 R2
|
||||
platform: x64
|
||||
|
||||
install:
|
||||
- copy c:\MinGW\bin\mingw32-make.exe c:\MinGW\bin\make.exe
|
||||
- set GOPATH=C:\Users\appveyor\go
|
||||
- set PATH=%PATH%;c:\MinGW\bin
|
||||
- set PATH=%PATH%;%GOPATH%\bin;c:\go\bin
|
||||
- set GOBIN=%GOPATH%\bin
|
||||
- go version
|
||||
- go env
|
||||
- make setup
|
||||
|
||||
build_script:
|
||||
- make ci
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
package retry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Function signature of retry if function
|
||||
type RetryIfFunc func(error) bool
|
||||
|
||||
// Function signature of OnRetry function
|
||||
// n = count of attempts
|
||||
type OnRetryFunc func(n uint, err error)
|
||||
|
||||
type DelayTypeFunc func(n uint, config *Config) time.Duration
|
||||
|
||||
type Config struct {
|
||||
attempts uint
|
||||
delay time.Duration
|
||||
maxDelay time.Duration
|
||||
maxJitter time.Duration
|
||||
onRetry OnRetryFunc
|
||||
retryIf RetryIfFunc
|
||||
delayType DelayTypeFunc
|
||||
lastErrorOnly bool
|
||||
context context.Context
|
||||
|
||||
maxBackOffN uint
|
||||
}
|
||||
|
||||
// Option represents an option for retry.
|
||||
type Option func(*Config)
|
||||
|
||||
// return the direct last error that came from the retried function
|
||||
// default is false (return wrapped errors with everything)
|
||||
func LastErrorOnly(lastErrorOnly bool) Option {
|
||||
return func(c *Config) {
|
||||
c.lastErrorOnly = lastErrorOnly
|
||||
}
|
||||
}
|
||||
|
||||
// Attempts set count of retry
|
||||
// default is 10
|
||||
func Attempts(attempts uint) Option {
|
||||
return func(c *Config) {
|
||||
c.attempts = attempts
|
||||
}
|
||||
}
|
||||
|
||||
// Delay set delay between retry
|
||||
// default is 100ms
|
||||
func Delay(delay time.Duration) Option {
|
||||
return func(c *Config) {
|
||||
c.delay = delay
|
||||
}
|
||||
}
|
||||
|
||||
// MaxDelay set maximum delay between retry
|
||||
// does not apply by default
|
||||
func MaxDelay(maxDelay time.Duration) Option {
|
||||
return func(c *Config) {
|
||||
c.maxDelay = maxDelay
|
||||
}
|
||||
}
|
||||
|
||||
// MaxJitter sets the maximum random Jitter between retries for RandomDelay
|
||||
func MaxJitter(maxJitter time.Duration) Option {
|
||||
return func(c *Config) {
|
||||
c.maxJitter = maxJitter
|
||||
}
|
||||
}
|
||||
|
||||
// DelayType set type of the delay between retries
|
||||
// default is BackOff
|
||||
func DelayType(delayType DelayTypeFunc) Option {
|
||||
return func(c *Config) {
|
||||
c.delayType = delayType
|
||||
}
|
||||
}
|
||||
|
||||
// BackOffDelay is a DelayType which increases delay between consecutive retries
|
||||
func BackOffDelay(n uint, config *Config) time.Duration {
|
||||
// 1 << 63 would overflow signed int64 (time.Duration), thus 62.
|
||||
const max uint = 62
|
||||
|
||||
if config.maxBackOffN == 0 {
|
||||
if config.delay <= 0 {
|
||||
config.delay = 1
|
||||
}
|
||||
config.maxBackOffN = max - uint(math.Floor(math.Log2(float64(config.delay))))
|
||||
}
|
||||
|
||||
if n > config.maxBackOffN {
|
||||
n = config.maxBackOffN
|
||||
}
|
||||
return config.delay << n
|
||||
}
|
||||
|
||||
// FixedDelay is a DelayType which keeps delay the same through all iterations
|
||||
func FixedDelay(_ uint, config *Config) time.Duration {
|
||||
return config.delay
|
||||
}
|
||||
|
||||
// RandomDelay is a DelayType which picks a random delay up to config.maxJitter
|
||||
func RandomDelay(_ uint, config *Config) time.Duration {
|
||||
return time.Duration(rand.Int63n(int64(config.maxJitter)))
|
||||
}
|
||||
|
||||
// CombineDelay is a DelayType the combines all of the specified delays into a new DelayTypeFunc
|
||||
func CombineDelay(delays ...DelayTypeFunc) DelayTypeFunc {
|
||||
const maxInt64 = uint64(math.MaxInt64)
|
||||
|
||||
return func(n uint, config *Config) time.Duration {
|
||||
var total uint64
|
||||
for _, delay := range delays {
|
||||
total += uint64(delay(n, config))
|
||||
if total > maxInt64 {
|
||||
total = maxInt64
|
||||
}
|
||||
}
|
||||
return time.Duration(total)
|
||||
}
|
||||
}
|
||||
|
||||
// OnRetry function callback are called each retry
|
||||
//
|
||||
// log each retry example:
|
||||
//
|
||||
// retry.Do(
|
||||
// func() error {
|
||||
// return errors.New("some error")
|
||||
// },
|
||||
// retry.OnRetry(func(n uint, err error) {
|
||||
// log.Printf("#%d: %s\n", n, err)
|
||||
// }),
|
||||
// )
|
||||
func OnRetry(onRetry OnRetryFunc) Option {
|
||||
return func(c *Config) {
|
||||
c.onRetry = onRetry
|
||||
}
|
||||
}
|
||||
|
||||
// RetryIf controls whether a retry should be attempted after an error
|
||||
// (assuming there are any retry attempts remaining)
|
||||
//
|
||||
// skip retry if special error example:
|
||||
//
|
||||
// retry.Do(
|
||||
// func() error {
|
||||
// return errors.New("special error")
|
||||
// },
|
||||
// retry.RetryIf(func(err error) bool {
|
||||
// if err.Error() == "special error" {
|
||||
// return false
|
||||
// }
|
||||
// return true
|
||||
// })
|
||||
// )
|
||||
//
|
||||
// By default RetryIf stops execution if the error is wrapped using `retry.Unrecoverable`,
|
||||
// so above example may also be shortened to:
|
||||
//
|
||||
// retry.Do(
|
||||
// func() error {
|
||||
// return retry.Unrecoverable(errors.New("special error"))
|
||||
// }
|
||||
// )
|
||||
func RetryIf(retryIf RetryIfFunc) Option {
|
||||
return func(c *Config) {
|
||||
c.retryIf = retryIf
|
||||
}
|
||||
}
|
||||
|
||||
// Context allow to set context of retry
|
||||
// default are Background context
|
||||
//
|
||||
// example of immediately cancellation (maybe it isn't the best example, but it describes behavior enough; I hope)
|
||||
// ctx, cancel := context.WithCancel(context.Background())
|
||||
// cancel()
|
||||
//
|
||||
// retry.Do(
|
||||
// func() error {
|
||||
// ...
|
||||
// },
|
||||
// retry.Context(ctx),
|
||||
// )
|
||||
func Context(ctx context.Context) Option {
|
||||
return func(c *Config) {
|
||||
c.context = ctx
|
||||
}
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
Simple library for retry mechanism
|
||||
|
||||
slightly inspired by [Try::Tiny::Retry](https://metacpan.org/pod/Try::Tiny::Retry)
|
||||
|
||||
SYNOPSIS
|
||||
|
||||
http get with retry:
|
||||
|
||||
url := "http://example.com"
|
||||
var body []byte
|
||||
|
||||
err := retry.Do(
|
||||
func() error {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
fmt.Println(body)
|
||||
|
||||
[next examples](https://github.com/avast/retry-go/tree/master/examples)
|
||||
|
||||
|
||||
SEE ALSO
|
||||
|
||||
* [giantswarm/retry-go](https://github.com/giantswarm/retry-go) - slightly complicated interface.
|
||||
|
||||
* [sethgrid/pester](https://github.com/sethgrid/pester) - only http retry for http calls with retries and backoff
|
||||
|
||||
* [cenkalti/backoff](https://github.com/cenkalti/backoff) - Go port of the exponential backoff algorithm from Google's HTTP Client Library for Java. Really complicated interface.
|
||||
|
||||
* [rafaeljesus/retry-go](https://github.com/rafaeljesus/retry-go) - looks good, slightly similar as this package, don't have 'simple' `Retry` method
|
||||
|
||||
* [matryer/try](https://github.com/matryer/try) - very popular package, nonintuitive interface (for me)
|
||||
|
||||
BREAKING CHANGES
|
||||
|
||||
1.0.2 -> 2.0.0
|
||||
|
||||
* argument of `retry.Delay` is final delay (no multiplication by `retry.Units` anymore)
|
||||
|
||||
* function `retry.Units` are removed
|
||||
|
||||
* [more about this breaking change](https://github.com/avast/retry-go/issues/7)
|
||||
|
||||
|
||||
0.3.0 -> 1.0.0
|
||||
|
||||
* `retry.Retry` function are changed to `retry.Do` function
|
||||
|
||||
* `retry.RetryCustom` (OnRetry) and `retry.RetryCustomWithOpts` functions are now implement via functions produces Options (aka `retry.OnRetry`)
|
||||
|
||||
|
||||
*/
|
||||
package retry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Function signature of retryable function
|
||||
type RetryableFunc func() error
|
||||
|
||||
var (
|
||||
DefaultAttempts = uint(10)
|
||||
DefaultDelay = 100 * time.Millisecond
|
||||
DefaultMaxJitter = 100 * time.Millisecond
|
||||
DefaultOnRetry = func(n uint, err error) {}
|
||||
DefaultRetryIf = IsRecoverable
|
||||
DefaultDelayType = CombineDelay(BackOffDelay, RandomDelay)
|
||||
DefaultLastErrorOnly = false
|
||||
DefaultContext = context.Background()
|
||||
)
|
||||
|
||||
func Do(retryableFunc RetryableFunc, opts ...Option) error {
|
||||
var n uint
|
||||
|
||||
//default
|
||||
config := &Config{
|
||||
attempts: DefaultAttempts,
|
||||
delay: DefaultDelay,
|
||||
maxJitter: DefaultMaxJitter,
|
||||
onRetry: DefaultOnRetry,
|
||||
retryIf: DefaultRetryIf,
|
||||
delayType: DefaultDelayType,
|
||||
lastErrorOnly: DefaultLastErrorOnly,
|
||||
context: DefaultContext,
|
||||
}
|
||||
|
||||
//apply opts
|
||||
for _, opt := range opts {
|
||||
opt(config)
|
||||
}
|
||||
|
||||
if err := config.context.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var errorLog Error
|
||||
if !config.lastErrorOnly {
|
||||
errorLog = make(Error, config.attempts)
|
||||
} else {
|
||||
errorLog = make(Error, 1)
|
||||
}
|
||||
|
||||
lastErrIndex := n
|
||||
for n < config.attempts {
|
||||
err := retryableFunc()
|
||||
|
||||
if err != nil {
|
||||
errorLog[lastErrIndex] = unpackUnrecoverable(err)
|
||||
|
||||
if !config.retryIf(err) {
|
||||
break
|
||||
}
|
||||
|
||||
config.onRetry(n, err)
|
||||
|
||||
// if this is last attempt - don't wait
|
||||
if n == config.attempts-1 {
|
||||
break
|
||||
}
|
||||
|
||||
delayTime := config.delayType(n, config)
|
||||
if config.maxDelay > 0 && delayTime > config.maxDelay {
|
||||
delayTime = config.maxDelay
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(delayTime):
|
||||
case <-config.context.Done():
|
||||
return config.context.Err()
|
||||
}
|
||||
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
n++
|
||||
if !config.lastErrorOnly {
|
||||
lastErrIndex = n
|
||||
}
|
||||
}
|
||||
|
||||
if config.lastErrorOnly {
|
||||
return errorLog[lastErrIndex]
|
||||
}
|
||||
return errorLog
|
||||
}
|
||||
|
||||
// Error type represents list of errors in retry
|
||||
type Error []error
|
||||
|
||||
// Error method return string representation of Error
|
||||
// It is an implementation of error interface
|
||||
func (e Error) Error() string {
|
||||
logWithNumber := make([]string, lenWithoutNil(e))
|
||||
for i, l := range e {
|
||||
if l != nil {
|
||||
logWithNumber[i] = fmt.Sprintf("#%d: %s", i+1, l.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("All attempts fail:\n%s", strings.Join(logWithNumber, "\n"))
|
||||
}
|
||||
|
||||
func lenWithoutNil(e Error) (count int) {
|
||||
for _, v := range e {
|
||||
if v != nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// WrappedErrors returns the list of errors that this Error is wrapping.
|
||||
// It is an implementation of the `errwrap.Wrapper` interface
|
||||
// in package [errwrap](https://github.com/hashicorp/errwrap) so that
|
||||
// `retry.Error` can be used with that library.
|
||||
func (e Error) WrappedErrors() []error {
|
||||
return e
|
||||
}
|
||||
|
||||
type unrecoverableError struct {
|
||||
error
|
||||
}
|
||||
|
||||
// Unrecoverable wraps an error in `unrecoverableError` struct
|
||||
func Unrecoverable(err error) error {
|
||||
return unrecoverableError{err}
|
||||
}
|
||||
|
||||
// IsRecoverable checks if error is an instance of `unrecoverableError`
|
||||
func IsRecoverable(err error) bool {
|
||||
_, isUnrecoverable := err.(unrecoverableError)
|
||||
return !isUnrecoverable
|
||||
}
|
||||
|
||||
func unpackUnrecoverable(err error) error {
|
||||
if unrecoverable, isUnrecoverable := err.(unrecoverableError); isUnrecoverable {
|
||||
return unrecoverable.error
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package caldav
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
cent "github.com/dolanor/caldav-go/caldav/entities"
|
||||
"github.com/dolanor/caldav-go/icalendar/components"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"github.com/dolanor/caldav-go/webdav"
|
||||
"github.com/dolanor/caldav-go/webdav/entities"
|
||||
)
|
||||
|
||||
var _ = log.Print
|
||||
|
||||
// a client for making WebDAV requests
|
||||
type Client webdav.Client
|
||||
|
||||
// downcasts the client to the WebDAV interface
|
||||
func (c *Client) WebDAV() *webdav.Client {
|
||||
return (*webdav.Client)(c)
|
||||
}
|
||||
|
||||
// returns the embedded CalDAV server reference
|
||||
func (c *Client) Server() *Server {
|
||||
return (*Server)(c.WebDAV().Server())
|
||||
}
|
||||
|
||||
// fetches a list of CalDAV features supported by the server
|
||||
// returns an error if the server does not support DAV
|
||||
func (c *Client) Features(path string) ([]string, error) {
|
||||
var cfeatures []string
|
||||
if features, err := c.WebDAV().Features(path); err != nil {
|
||||
return cfeatures, utils.NewError(c.Features, "unable to detect features", c, err)
|
||||
} else {
|
||||
for _, feature := range features {
|
||||
if strings.HasPrefix(feature, "calendar-") {
|
||||
cfeatures = append(cfeatures, feature)
|
||||
}
|
||||
}
|
||||
return cfeatures, nil
|
||||
}
|
||||
}
|
||||
|
||||
// fetches a list of CalDAV features and checks if a certain one is supported by the server
|
||||
// returns an error if the server does not support DAV
|
||||
func (c *Client) SupportsFeature(name string, path string) (bool, error) {
|
||||
if features, err := c.Features(path); err != nil {
|
||||
return false, utils.NewError(c.SupportsFeature, "feature detection failed", c, err)
|
||||
} else {
|
||||
var test = fmt.Sprintf("calendar-%s", name)
|
||||
for _, feature := range features {
|
||||
if feature == test {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// fetches a list of CalDAV features and checks if a certain one is supported by the server
|
||||
// returns an error if the server does not support DAV
|
||||
func (c *Client) ValidateServer(path string) error {
|
||||
if found, err := c.SupportsFeature("access", path); err != nil {
|
||||
return utils.NewError(c.SupportsFeature, "feature detection failed", c, err)
|
||||
} else if !found {
|
||||
return utils.NewError(c.SupportsFeature, "calendar access feature missing", c, nil)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// creates a new calendar collection on a given path
|
||||
func (c *Client) MakeCalendar(path string) error {
|
||||
if req, err := c.Server().NewRequest("MKCALENDAR", path); err != nil {
|
||||
return utils.NewError(c.MakeCalendar, "unable to create request", c, err)
|
||||
} else if resp, err := c.Do(req); err != nil {
|
||||
return utils.NewError(c.MakeCalendar, "unable to execute request", c, err)
|
||||
} else if resp.StatusCode != http.StatusCreated {
|
||||
err := new(entities.Error)
|
||||
resp.Decode(err)
|
||||
msg := fmt.Sprintf("unexpected server response %s", resp.Status)
|
||||
return utils.NewError(c.MakeCalendar, msg, c, err)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// creates or updates one or more events on the remote CalDAV server
|
||||
func (c *Client) PutEvents(path string, events ...*components.Event) error {
|
||||
if len(events) <= 0 {
|
||||
return utils.NewError(c.PutEvents, "no calendar events provided", c, nil)
|
||||
} else if cal := components.NewCalendar(events...); events[0] == nil {
|
||||
return utils.NewError(c.PutEvents, "icalendar event must not be nil", c, nil)
|
||||
} else if err := c.PutCalendars(path, cal); err != nil {
|
||||
return utils.NewError(c.PutEvents, "unable to put calendar", c, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// creates or updates one or more calendars on the remote CalDAV server
|
||||
func (c *Client) PutCalendars(path string, calendars ...*components.Calendar) error {
|
||||
if req, err := c.Server().NewRequest("PUT", path, calendars); err != nil {
|
||||
return utils.NewError(c.PutCalendars, "unable to encode request", c, err)
|
||||
} else if resp, err := c.Do(req); err != nil {
|
||||
return utils.NewError(c.PutCalendars, "unable to execute request", c, err)
|
||||
} else if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
|
||||
err := new(entities.Error)
|
||||
resp.WebDAV().Decode(err)
|
||||
msg := fmt.Sprintf("unexpected server response %s", resp.Status)
|
||||
return utils.NewError(c.PutCalendars, msg, c, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// attempts to fetch an event on the remote CalDAV server
|
||||
func (c *Client) GetEvents(path string) ([]*components.Event, error) {
|
||||
cal := new(components.Calendar)
|
||||
if req, err := c.Server().NewRequest("GET", path); err != nil {
|
||||
return nil, utils.NewError(c.GetEvents, "unable to create request", c, err)
|
||||
} else if resp, err := c.Do(req); err != nil {
|
||||
return nil, utils.NewError(c.GetEvents, "unable to execute request", c, err)
|
||||
} else if resp.StatusCode != http.StatusOK {
|
||||
err := new(entities.Error)
|
||||
resp.WebDAV().Decode(err)
|
||||
msg := fmt.Sprintf("unexpected server response %s", resp.Status)
|
||||
return nil, utils.NewError(c.GetEvents, msg, c, err)
|
||||
} else if err := resp.Decode(cal); err != nil {
|
||||
return nil, utils.NewError(c.GetEvents, "unable to decode response", c, err)
|
||||
} else {
|
||||
return cal.Events, nil
|
||||
}
|
||||
}
|
||||
|
||||
// attempts to fetch an event on the remote CalDAV server
|
||||
func (c *Client) QueryEvents(path string, query *cent.CalendarQuery) (events []*components.Event, oerr error) {
|
||||
ms := new(cent.Multistatus)
|
||||
if req, err := c.Server().WebDAV().NewRequest("REPORT", path, query); err != nil {
|
||||
oerr = utils.NewError(c.QueryEvents, "unable to create request", c, err)
|
||||
} else if req.Http().Native().Header.Set("Depth", string(webdav.Depth1)); false {
|
||||
} else if resp, err := c.WebDAV().Do(req); err != nil {
|
||||
oerr = utils.NewError(c.QueryEvents, "unable to execute request", c, err)
|
||||
} else if resp.StatusCode == http.StatusNotFound {
|
||||
return // no events if not found
|
||||
} else if resp.StatusCode != webdav.StatusMulti {
|
||||
err := new(entities.Error)
|
||||
msg := fmt.Sprintf("unexpected server response %s", resp.Status)
|
||||
resp.Decode(err)
|
||||
oerr = utils.NewError(c.QueryEvents, msg, c, err)
|
||||
} else if err := resp.Decode(ms); err != nil {
|
||||
msg := "unable to decode response"
|
||||
oerr = utils.NewError(c.QueryEvents, msg, c, err)
|
||||
} else {
|
||||
for i, r := range ms.Responses {
|
||||
for j, p := range r.PropStats {
|
||||
if p.Prop == nil || p.Prop.CalendarData == nil {
|
||||
continue
|
||||
} else if cal, err := p.Prop.CalendarData.CalendarComponent(); err != nil {
|
||||
msg := fmt.Sprintf("unable to decode property %d of response %d", j, i)
|
||||
oerr = utils.NewError(c.QueryEvents, msg, c, err)
|
||||
return
|
||||
} else {
|
||||
events = append(events, cal.Events...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// executes a CalDAV request
|
||||
func (c *Client) Do(req *Request) (*Response, error) {
|
||||
if resp, err := c.WebDAV().Do((*webdav.Request)(req)); err != nil {
|
||||
return nil, utils.NewError(c.Do, "unable to execute CalDAV request", c, err)
|
||||
} else {
|
||||
return NewResponse(resp), nil
|
||||
}
|
||||
}
|
||||
|
||||
// creates a new client for communicating with an WebDAV server
|
||||
func NewClient(server *Server, native *http.Client) *Client {
|
||||
return (*Client)(webdav.NewClient((*webdav.Server)(server), native))
|
||||
}
|
||||
|
||||
// creates a new client for communicating with a WebDAV server
|
||||
// uses the default HTTP client from net/http
|
||||
func NewDefaultClient(server *Server) *Client {
|
||||
return NewClient(server, http.DefaultClient)
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"github.com/dolanor/caldav-go/caldav/values"
|
||||
"github.com/dolanor/caldav-go/icalendar"
|
||||
"github.com/dolanor/caldav-go/icalendar/components"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// a CalDAV calendar data object
|
||||
type CalendarData struct {
|
||||
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav calendar-data"`
|
||||
Component *Component `xml:",omitempty"`
|
||||
RecurrenceSetLimit *RecurrenceSetLimit `xml:",omitempty"`
|
||||
ExpandRecurrenceSet *ExpandRecurrenceSet `xml:",omitempty"`
|
||||
Content string `xml:",chardata"`
|
||||
}
|
||||
|
||||
func (c *CalendarData) CalendarComponent() (*components.Calendar, error) {
|
||||
cal := new(components.Calendar)
|
||||
if content := strings.TrimSpace(c.Content); content == "" {
|
||||
return nil, utils.NewError(c.CalendarComponent, "no calendar data to decode", c, nil)
|
||||
} else if err := icalendar.Unmarshal(content, cal); err != nil {
|
||||
return nil, utils.NewError(c.CalendarComponent, "decoding calendar data failed", c, err)
|
||||
} else {
|
||||
return cal, nil
|
||||
}
|
||||
}
|
||||
|
||||
// an iCalendar specifier for returned calendar data
|
||||
type Component struct {
|
||||
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav comp"`
|
||||
Properties []*PropertyName `xml:",omitempty"`
|
||||
Components []*Component `xml:",omitempty"`
|
||||
}
|
||||
|
||||
// used to restrict recurring event data to a particular time range
|
||||
type RecurrenceSetLimit struct {
|
||||
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav limit-recurrence-set"`
|
||||
StartTime *values.DateTime `xml:"start,attr"`
|
||||
EndTime *values.DateTime `xml:"end,attr"`
|
||||
}
|
||||
|
||||
// used to expand recurring events into individual calendar event data
|
||||
type ExpandRecurrenceSet struct {
|
||||
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav expand"`
|
||||
StartTime *values.DateTime `xml:"start,attr"`
|
||||
EndTime *values.DateTime `xml:"end,attr"`
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"github.com/dolanor/caldav-go/caldav/values"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"github.com/dolanor/caldav-go/webdav/entities"
|
||||
"time"
|
||||
)
|
||||
|
||||
// a CalDAV calendar query object
|
||||
type CalendarQuery struct {
|
||||
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav calendar-query"`
|
||||
Prop *Prop `xml:",omitempty"`
|
||||
AllProp *entities.AllProp `xml:",omitempty"`
|
||||
Filter *Filter `xml:",omitempty"`
|
||||
}
|
||||
|
||||
// creates a new CalDAV query for iCalendar events from a particular time range
|
||||
func NewEventRangeQuery(start, end time.Time) (*CalendarQuery, error) {
|
||||
|
||||
var err error
|
||||
var dtstart, dtend *values.DateTime
|
||||
if dtstart, err = values.NewDateTime("start", start); err != nil {
|
||||
return nil, utils.NewError(NewEventRangeQuery, "unable to encode start time", start, err)
|
||||
} else if dtend, err = values.NewDateTime("end", end); err != nil {
|
||||
return nil, utils.NewError(NewEventRangeQuery, "unable to encode end time", end, err)
|
||||
}
|
||||
|
||||
// construct the query object
|
||||
query := new(CalendarQuery)
|
||||
|
||||
// request all calendar data
|
||||
query.Prop = new(Prop)
|
||||
query.Prop.CalendarData = new(CalendarData)
|
||||
|
||||
// expand recurring events
|
||||
query.Prop.CalendarData.ExpandRecurrenceSet = new(ExpandRecurrenceSet)
|
||||
query.Prop.CalendarData.ExpandRecurrenceSet.StartTime = dtstart
|
||||
query.Prop.CalendarData.ExpandRecurrenceSet.EndTime = dtend
|
||||
|
||||
// filter down calendar data to only iCalendar data
|
||||
query.Filter = new(Filter)
|
||||
query.Filter.ComponentFilter = new(ComponentFilter)
|
||||
query.Filter.ComponentFilter.Name = values.CalendarComponentName
|
||||
|
||||
// filter down iCalendar data to only events
|
||||
query.Filter.ComponentFilter.ComponentFilter = new(ComponentFilter)
|
||||
query.Filter.ComponentFilter.ComponentFilter.Name = values.EventComponentName
|
||||
|
||||
// filter down the events to only those that fall within the time range
|
||||
query.Filter.ComponentFilter.ComponentFilter.TimeRange = new(TimeRange)
|
||||
query.Filter.ComponentFilter.ComponentFilter.TimeRange.StartTime = dtstart
|
||||
query.Filter.ComponentFilter.ComponentFilter.TimeRange.EndTime = dtend
|
||||
|
||||
// return the event query
|
||||
return query, nil
|
||||
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"github.com/dolanor/caldav-go/caldav/values"
|
||||
"github.com/dolanor/caldav-go/icalendar/properties"
|
||||
)
|
||||
|
||||
// a CalDAV query filter entity
|
||||
type Filter struct {
|
||||
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav filter"`
|
||||
ComponentFilter *ComponentFilter `xml:",omitempty"`
|
||||
}
|
||||
|
||||
// used to filter down calendar components, such as VCALENDAR > VEVENT
|
||||
type ComponentFilter struct {
|
||||
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav comp-filter"`
|
||||
Name values.ComponentName `xml:"name,attr"`
|
||||
ComponentFilter *ComponentFilter `xml:",omitempty"`
|
||||
TimeRange *TimeRange `xml:",omitempty"`
|
||||
PropertyFilter *PropertyFilter `xml:",omitempty"`
|
||||
ParameterFilter *ParameterFilter `xml:",omitempty"`
|
||||
}
|
||||
|
||||
// used to restrict component filters to a particular time range
|
||||
type TimeRange struct {
|
||||
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav time-range"`
|
||||
StartTime *values.DateTime `xml:"start,attr"`
|
||||
EndTime *values.DateTime `xml:"end,attr"`
|
||||
}
|
||||
|
||||
// used to restrict component filters to a property value
|
||||
type PropertyFilter struct {
|
||||
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav prop-filter"`
|
||||
Name properties.PropertyName `xml:"name,attr"`
|
||||
TextMatch *TextMatch `xml:",omitempty"`
|
||||
ParameterFilter *ParameterFilter `xml:",omitempty"`
|
||||
}
|
||||
|
||||
// used to restrict component filters to a parameter value
|
||||
type ParameterFilter struct {
|
||||
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav param-filter"`
|
||||
Name properties.ParameterName `xml:"name,attr"`
|
||||
TextMatch *TextMatch `xml:",omitempty"`
|
||||
}
|
||||
|
||||
// used to match properties by text value
|
||||
type TextMatch struct {
|
||||
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav text-match"`
|
||||
Collation values.TextCollation `xml:"collation,attr,omitempty"`
|
||||
NegateCondition values.HumanBoolean `xml:"attr,negate-condition,omitempty"`
|
||||
Content string `xml:",innerxml"`
|
||||
}
|
||||
|
||||
// creates a new CalDAV property value matcher
|
||||
func NewPropertyMatcher(name properties.PropertyName, content string) *PropertyFilter {
|
||||
pf := new(PropertyFilter)
|
||||
pf.Name = name
|
||||
pf.TextMatch = new(TextMatch)
|
||||
pf.TextMatch.Content = content
|
||||
return pf
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package entities
|
||||
|
||||
import "encoding/xml"
|
||||
|
||||
// metadata about a property
|
||||
type PropStat struct {
|
||||
XMLName xml.Name `xml:"propstat"`
|
||||
Status string `xml:"status"`
|
||||
Prop *Prop `xml:",omitempty"`
|
||||
}
|
||||
|
||||
// a multistatus response entity
|
||||
type Response struct {
|
||||
XMLName xml.Name `xml:"response"`
|
||||
Href string `xml:"href"`
|
||||
PropStats []*PropStat `xml:"propstat,omitempty"`
|
||||
}
|
||||
|
||||
// a request to find properties on an an entity or collection
|
||||
type Multistatus struct {
|
||||
XMLName xml.Name `xml:"DAV: multistatus"`
|
||||
Responses []*Response `xml:"response,omitempty"`
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"github.com/dolanor/caldav-go/webdav/entities"
|
||||
)
|
||||
|
||||
// a CalDAV Property resource
|
||||
type Prop struct {
|
||||
XMLName xml.Name `xml:"DAV: prop"`
|
||||
GetContentType string `xml:"getcontenttype,omitempty"`
|
||||
DisplayName string `xml:"displayname,omitempty"`
|
||||
CalendarData *CalendarData `xml:",omitempty"`
|
||||
ResourceType *entities.ResourceType `xml:",omitempty"`
|
||||
CTag string `xml:"http://calendarserver.org/ns/ getctag,omitempty"`
|
||||
ETag string `xml:"http://calendarserver.org/ns/ getetag,omitempty"`
|
||||
}
|
||||
|
||||
// used to restrict properties returned in calendar data
|
||||
type PropertyName struct {
|
||||
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav prop"`
|
||||
Name string `xml:"name,attr"`
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package caldav
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/dolanor/caldav-go/http"
|
||||
"github.com/dolanor/caldav-go/icalendar"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"github.com/dolanor/caldav-go/webdav"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var _ = log.Print
|
||||
|
||||
// an CalDAV request object
|
||||
type Request webdav.Request
|
||||
|
||||
// downcasts the request to the WebDAV interface
|
||||
func (r *Request) WebDAV() *webdav.Request {
|
||||
return (*webdav.Request)(r)
|
||||
}
|
||||
|
||||
// creates a new CalDAV request object
|
||||
func NewRequest(method string, urlstr string, icaldata ...interface{}) (*Request, error) {
|
||||
if buffer, err := icalToReadCloser(icaldata...); err != nil {
|
||||
return nil, utils.NewError(NewRequest, "unable to encode icalendar data", icaldata, err)
|
||||
} else if r, err := http.NewRequest(method, urlstr, buffer); err != nil {
|
||||
return nil, utils.NewError(NewRequest, "unable to create request", urlstr, err)
|
||||
} else {
|
||||
if buffer != nil {
|
||||
// set the content type to XML if we have a body
|
||||
r.Native().Header.Set("Content-Type", "text/calendar; charset=UTF-8")
|
||||
}
|
||||
return (*Request)(r), nil
|
||||
}
|
||||
}
|
||||
|
||||
func icalToReadCloser(icaldata ...interface{}) (io.ReadCloser, error) {
|
||||
var buffer []string
|
||||
for _, icaldatum := range icaldata {
|
||||
if encoded, err := icalendar.Marshal(icaldatum); err != nil {
|
||||
return nil, utils.NewError(icalToReadCloser, "unable to encode as icalendar data", icaldatum, err)
|
||||
} else {
|
||||
// log.Printf("OUT: %+v", encoded)
|
||||
buffer = append(buffer, encoded)
|
||||
}
|
||||
}
|
||||
if len(buffer) > 0 {
|
||||
var encoded = strings.Join(buffer, "\n")
|
||||
return ioutil.NopCloser(bytes.NewBuffer([]byte(encoded))), nil
|
||||
} else {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package caldav
|
||||
|
||||
import (
|
||||
"github.com/dolanor/caldav-go/icalendar"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"github.com/dolanor/caldav-go/webdav"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
)
|
||||
|
||||
var _ = log.Print
|
||||
|
||||
// a WebDAV response object
|
||||
type Response webdav.Response
|
||||
|
||||
// downcasts the response to the WebDAV interface
|
||||
func (r *Response) WebDAV() *webdav.Response {
|
||||
return (*webdav.Response)(r)
|
||||
}
|
||||
|
||||
// decodes a CalDAV iCalendar response into the provided interface
|
||||
func (r *Response) Decode(into interface{}) error {
|
||||
if body := r.Body; body == nil {
|
||||
return nil
|
||||
} else if encoded, err := ioutil.ReadAll(body); err != nil {
|
||||
return utils.NewError(r.Decode, "unable to read response body", r, err)
|
||||
} else {
|
||||
// log.Printf("IN: %+v", string(encoded))
|
||||
if err := icalendar.Unmarshal(string(encoded), into); err != nil {
|
||||
return utils.NewError(r.Decode, "unable to decode response body", r, err)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// creates a new WebDAV response object
|
||||
func NewResponse(response *webdav.Response) *Response {
|
||||
return (*Response)(response)
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package caldav
|
||||
|
||||
import (
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"github.com/dolanor/caldav-go/webdav"
|
||||
)
|
||||
|
||||
// a server that accepts CalDAV requests
|
||||
type Server webdav.Server
|
||||
|
||||
// NewServer creates a reference to a CalDAV server.
|
||||
// host is the url to access the server, stopping at the port:
|
||||
// https://user:password@host:port/
|
||||
func NewServer(host string) (*Server, error) {
|
||||
if s, err := webdav.NewServer(host); err != nil {
|
||||
return nil, utils.NewError(NewServer, "unable to create WebDAV server", host, err)
|
||||
} else {
|
||||
return (*Server)(s), nil
|
||||
}
|
||||
}
|
||||
|
||||
// downcasts the server to the WebDAV interface
|
||||
func (s *Server) WebDAV() *webdav.Server {
|
||||
return (*webdav.Server)(s)
|
||||
}
|
||||
|
||||
// creates a new CalDAV request object
|
||||
func (s *Server) NewRequest(method string, path string, icaldata ...interface{}) (*Request, error) {
|
||||
return NewRequest(method, s.WebDAV().Http().AbsUrlStr(path), icaldata...)
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package values
|
||||
|
||||
type ComponentName string
|
||||
|
||||
const (
|
||||
CalendarComponentName ComponentName = "VCALENDAR"
|
||||
EventComponentName = "VEVENT"
|
||||
)
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"github.com/dolanor/caldav-go/icalendar/values"
|
||||
"time"
|
||||
)
|
||||
|
||||
// a representation of a date and time for iCalendar
|
||||
type DateTime struct {
|
||||
name string
|
||||
t time.Time
|
||||
}
|
||||
|
||||
// creates a new caldav datetime representation, must be in UTC
|
||||
func NewDateTime(name string, t time.Time) (*DateTime, error) {
|
||||
if t.Location() != time.UTC {
|
||||
return nil, errors.New("CalDAV datetime must be in UTC")
|
||||
} else {
|
||||
return &DateTime{name: name, t: t.Truncate(time.Second)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// encodes the datetime value for the iCalendar specification
|
||||
func (d *DateTime) MarshalXMLAttr(name xml.Name) (xml.Attr, error) {
|
||||
layout := values.UTCDateTimeFormatString
|
||||
value := d.t.Format(layout)
|
||||
attr := xml.Attr{Name: name, Value: value}
|
||||
return attr, nil
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package values
|
||||
|
||||
type HumanBoolean string
|
||||
|
||||
const (
|
||||
YesHumanBoolean HumanBoolean = "yes"
|
||||
NoHumanBoolean = "no"
|
||||
)
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package values
|
||||
|
||||
type TextCollation string
|
||||
|
||||
const (
|
||||
OctetTextCollation TextCollation = "i;octet"
|
||||
ASCIICaseMapCollation = "i;ascii-casemap"
|
||||
)
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// a client for making HTTP requests
|
||||
type Client struct {
|
||||
native *http.Client
|
||||
server *Server
|
||||
requestHeaders map[string]string
|
||||
}
|
||||
|
||||
func (c *Client) SetHeader(key string, value string) {
|
||||
if c.requestHeaders == nil {
|
||||
c.requestHeaders = map[string]string{}
|
||||
}
|
||||
c.requestHeaders[key] = value
|
||||
}
|
||||
|
||||
// downcasts to the native HTTP interface
|
||||
func (c *Client) Native() *http.Client {
|
||||
return c.native
|
||||
}
|
||||
|
||||
// returns the embedded HTTP server reference
|
||||
func (c *Client) Server() *Server {
|
||||
return c.server
|
||||
}
|
||||
|
||||
// executes an HTTP request
|
||||
func (c *Client) Do(req *Request) (*Response, error) {
|
||||
for key, value := range c.requestHeaders {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
if resp, err := c.Native().Do((*http.Request)(req)); err != nil {
|
||||
return nil, utils.NewError(c.Do, "unable to execute HTTP request", c, err)
|
||||
} else {
|
||||
return NewResponse(resp), nil
|
||||
}
|
||||
}
|
||||
|
||||
// creates a new client for communicating with an HTTP server
|
||||
func NewClient(server *Server, native *http.Client) *Client {
|
||||
return &Client{server: server, native: native}
|
||||
}
|
||||
|
||||
// creates a new client for communicating with a server
|
||||
// uses the default HTTP client from net/http
|
||||
func NewDefaultClient(server *Server) *Client {
|
||||
return NewClient(server, http.DefaultClient)
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// an HTTP request object
|
||||
type Request http.Request
|
||||
|
||||
// downcasts the request to the native HTTP interface
|
||||
func (r *Request) Native() *http.Request {
|
||||
return (*http.Request)(r)
|
||||
}
|
||||
|
||||
// creates a new HTTP request object
|
||||
func NewRequest(method string, urlstr string, body ...io.ReadCloser) (*Request, error) {
|
||||
|
||||
var err error
|
||||
var r = new(http.Request)
|
||||
|
||||
if len(body) > 0 && body[0] != nil {
|
||||
r, err = http.NewRequest(method, urlstr, body[0])
|
||||
} else {
|
||||
r, err = http.NewRequest(method, urlstr, nil)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, utils.NewError(NewRequest, "unable to create request", urlstr, err)
|
||||
} else if auth := r.URL.User; auth != nil {
|
||||
pass, _ := auth.Password()
|
||||
r.SetBasicAuth(auth.Username(), pass)
|
||||
r.URL.User = nil
|
||||
}
|
||||
|
||||
return (*Request)(r), nil
|
||||
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// an HTTP response object
|
||||
type Response http.Response
|
||||
|
||||
// downcasts the response to the native HTTP interface
|
||||
func (r *Response) Native() *http.Response {
|
||||
return (*http.Response)(r)
|
||||
}
|
||||
|
||||
// creates a new HTTP response object
|
||||
func NewResponse(response *http.Response) *Response {
|
||||
return (*Response)(response)
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"io"
|
||||
"log"
|
||||
"net/url"
|
||||
spath "path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var _ = log.Print
|
||||
|
||||
// a server that accepts HTTP requests
|
||||
type Server struct {
|
||||
baseUrl *url.URL
|
||||
}
|
||||
|
||||
// creates a reference to an http server
|
||||
func NewServer(baseUrlStr string) (*Server, error) {
|
||||
var err error
|
||||
var s = new(Server)
|
||||
if s.baseUrl, err = url.Parse(baseUrlStr); err != nil {
|
||||
return nil, utils.NewError(NewServer, "unable to parse server base url", baseUrlStr, err)
|
||||
} else {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
|
||||
// converts a path name to an absolute URL
|
||||
func (s *Server) UserInfo() *url.Userinfo {
|
||||
return s.baseUrl.User
|
||||
}
|
||||
|
||||
// converts a path name to an absolute URL
|
||||
func (s *Server) AbsUrlStr(path string) string {
|
||||
uri := *s.baseUrl
|
||||
uri.Path = spath.Join(uri.Path, path)
|
||||
if strings.HasSuffix(path, "/") {
|
||||
uri.Path = uri.Path + "/"
|
||||
}
|
||||
return uri.String()
|
||||
}
|
||||
|
||||
// creates a new HTTP request object
|
||||
func (s *Server) NewRequest(method string, path string, body ...io.ReadCloser) (*Request, error) {
|
||||
return NewRequest(method, s.AbsUrlStr(path), body...)
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/dolanor/caldav-go/icalendar/values"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Calendar struct {
|
||||
|
||||
// specifies the identifier corresponding to the highest version number or the minimum and maximum
|
||||
// range of the iCalendar specification that is required in order to interpret the iCalendar object.
|
||||
Version string `ical:",2.0"`
|
||||
|
||||
// specifies the identifier for the product that created the iCalendar object
|
||||
ProductId string `ical:"prodid,-//dolanor/caldav-go//NONSGML v1.0.0//EN"`
|
||||
|
||||
// specifies the text value that uniquely identifies the "VTIMEZONE" calendar component.
|
||||
TimeZoneId string `ical:"tzid,omitempty"`
|
||||
|
||||
// defines the iCalendar object method associated with the calendar object.
|
||||
Method values.Method `ical:",omitempty"`
|
||||
|
||||
// defines the calendar scale used for the calendar information specified in the iCalendar object.
|
||||
CalScale values.CalScale `ical:",omitempty"`
|
||||
|
||||
// defines the different timezones used by the various components nested within
|
||||
TimeZones []*TimeZone `ical:",omitempty"`
|
||||
|
||||
// unique events to be stored together in the icalendar file
|
||||
Events []*Event `ical:",omitempty"`
|
||||
}
|
||||
|
||||
func (c *Calendar) UseTimeZone(location *time.Location) *TimeZone {
|
||||
tz := NewDynamicTimeZone(location)
|
||||
c.TimeZones = append(c.TimeZones, tz)
|
||||
c.TimeZoneId = tz.Id
|
||||
return tz
|
||||
}
|
||||
|
||||
func (c *Calendar) UsingTimeZone() bool {
|
||||
return len(c.TimeZoneId) > 0
|
||||
}
|
||||
|
||||
func (c *Calendar) UsingGlobalTimeZone() bool {
|
||||
return c.UsingTimeZone() && c.TimeZoneId[0] == '/'
|
||||
}
|
||||
|
||||
func (c *Calendar) ValidateICalValue() error {
|
||||
|
||||
for i, e := range c.Events {
|
||||
|
||||
if e == nil {
|
||||
continue // skip nil events
|
||||
}
|
||||
|
||||
if err := e.ValidateICalValue(); err != nil {
|
||||
msg := fmt.Sprintf("event %d failed validation", i)
|
||||
return utils.NewError(c.ValidateICalValue, msg, c, err)
|
||||
}
|
||||
|
||||
if e.DateStart == nil && c.Method == "" {
|
||||
msg := fmt.Sprintf("no value for method and no start date defined on event %d", i)
|
||||
return utils.NewError(c.ValidateICalValue, msg, c, nil)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if c.UsingTimeZone() && !c.UsingGlobalTimeZone() {
|
||||
for i, t := range c.TimeZones {
|
||||
if t == nil || t.Id != c.TimeZoneId {
|
||||
msg := fmt.Sprintf("timezone ID does not match timezone %d", i)
|
||||
return utils.NewError(c.ValidateICalValue, msg, c, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func NewCalendar(events ...*Event) *Calendar {
|
||||
cal := new(Calendar)
|
||||
cal.Events = events
|
||||
return cal
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"github.com/dolanor/caldav-go/icalendar/values"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
|
||||
// defines the persistent, globally unique identifier for the calendar component.
|
||||
UID string `ical:",required"`
|
||||
|
||||
// indicates the date/time that the instance of the iCalendar object was created.
|
||||
DateStamp *values.DateTime `ical:"dtstamp,required"`
|
||||
|
||||
// specifies when the calendar component begins.
|
||||
DateStart *values.DateTime `ical:"dtstart,required"`
|
||||
|
||||
// specifies the date and time that a calendar component ends.
|
||||
DateEnd *values.DateTime `ical:"dtend,omitempty"`
|
||||
|
||||
// specifies a positive duration of time.
|
||||
Duration *values.Duration `ical:",omitempty"`
|
||||
|
||||
// defines the access classification for a calendar component.
|
||||
AccessClassification values.EventAccessClassification `ical:"class,omitempty"`
|
||||
|
||||
// specifies the date and time that the calendar information was created by the calendar user agent in the
|
||||
// calendar store.
|
||||
// Note: This is analogous to the creation date and time for a file in the file system.
|
||||
Created *values.DateTime `ical:",omitempty"`
|
||||
|
||||
// provides a more complete description of the calendar component, than that provided by the Summary property.
|
||||
Description string `ical:",omitempty"`
|
||||
|
||||
// specifies information related to the global position for the activity specified by a calendar component.
|
||||
Geo *values.Geo `ical:",omitempty"`
|
||||
|
||||
// specifies the date and time that the information associated with the calendar component was last revised in the
|
||||
// calendar store.
|
||||
// Note: This is analogous to the modification date and time for a file in the file system.
|
||||
LastModified *values.DateTime `ical:"last-modified,omitempty"`
|
||||
|
||||
// defines the intended venue for the activity defined by a calendar component.
|
||||
Location *values.Location `ical:",omitempty"`
|
||||
|
||||
// defines the organizer for a calendar component.
|
||||
Organizer *values.OrganizerContact `ical:",omitempty"`
|
||||
|
||||
// defines the relative priority for a calendar component.
|
||||
Priority int `ical:",omitempty"`
|
||||
|
||||
// defines the revision sequence number of the calendar component within a sequence of revisions.
|
||||
Sequence int `ical:",omitempty"`
|
||||
|
||||
// efines the overall status or confirmation for the calendar component.
|
||||
Status values.EventStatus `ical:",omitempty"`
|
||||
|
||||
// defines a short summary or subject for the calendar component.
|
||||
Summary string `ical:",omitempty"`
|
||||
|
||||
// defines whether an event is transparent or not to busy time searches.
|
||||
values.TimeTransparency `ical:"transp,omitempty"`
|
||||
|
||||
// defines a Uniform Resource Locator (URL) associated with the iCalendar object.
|
||||
Url *values.Url `ical:",omitempty"`
|
||||
|
||||
// used in conjunction with the "UID" and "SEQUENCE" property to identify a specific instance of a recurring
|
||||
// event calendar component. The property value is the effective value of the DateStart property of the
|
||||
// recurrence instance.
|
||||
RecurrenceId *values.DateTime `ical:"recurrence_id,omitempty"`
|
||||
|
||||
// defines a rule or repeating pattern for recurring events, to-dos, or time zone definitions.
|
||||
RecurrenceRules []*values.RecurrenceRule `ical:",omitempty"`
|
||||
|
||||
// property provides the capability to associate a document object with a calendar component.
|
||||
Attachment *values.Url `ical:"attach,omitempty"`
|
||||
|
||||
// defines an "Attendee" within a calendar component.
|
||||
Attendees []*values.AttendeeContact `ical:",omitempty"`
|
||||
|
||||
// defines the categories for a calendar component.
|
||||
Categories *values.CSV `ical:",omitempty"`
|
||||
|
||||
// specifies non-processing information intended to provide a comment to the calendar user.
|
||||
Comments []values.Comment `ical:",omitempty"`
|
||||
|
||||
// used to represent contact information or alternately a reference to contact information associated with the calendar component.
|
||||
ContactInfo *values.CSV `ical:"contact,omitempty"`
|
||||
|
||||
// defines the list of date/time exceptions for a recurring calendar component.
|
||||
*values.ExceptionDateTimes `ical:",omitempty"`
|
||||
|
||||
// defines the list of date/times for a recurrence set.
|
||||
*values.RecurrenceDateTimes `ical:",omitempty"`
|
||||
|
||||
// used to represent a relationship or reference between one calendar component and another.
|
||||
RelatedTo *values.Url `ical:"related-to,omitempty"`
|
||||
|
||||
// defines the equipment or resources anticipated for an activity specified by a calendar entity.
|
||||
Resources *values.CSV `ical:",omitempty"`
|
||||
}
|
||||
|
||||
// validates the event internals
|
||||
func (e *Event) ValidateICalValue() error {
|
||||
|
||||
if e.UID == "" {
|
||||
return utils.NewError(e.ValidateICalValue, "the UID value must be set", e, nil)
|
||||
}
|
||||
|
||||
if e.DateStart == nil {
|
||||
return utils.NewError(e.ValidateICalValue, "event start date must be set", e, nil)
|
||||
}
|
||||
|
||||
if e.DateEnd == nil && e.Duration == nil {
|
||||
return utils.NewError(e.ValidateICalValue, "event end date or duration must be set", e, nil)
|
||||
}
|
||||
|
||||
if e.DateEnd != nil && e.Duration != nil {
|
||||
return utils.NewError(e.ValidateICalValue, "event end date and duration are mutually exclusive fields", e, nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
// adds one or more recurrence rule to the event
|
||||
func (e *Event) AddRecurrenceRules(r ...*values.RecurrenceRule) {
|
||||
e.RecurrenceRules = append(e.RecurrenceRules, r...)
|
||||
}
|
||||
|
||||
// adds one or more recurrence rule exception to the event
|
||||
func (e *Event) AddRecurrenceExceptions(d ...*values.DateTime) {
|
||||
if e.ExceptionDateTimes == nil {
|
||||
e.ExceptionDateTimes = new(values.ExceptionDateTimes)
|
||||
}
|
||||
*e.ExceptionDateTimes = append(*e.ExceptionDateTimes, d...)
|
||||
}
|
||||
|
||||
// checks to see if the event is a recurrence
|
||||
func (e *Event) IsRecurrence() bool {
|
||||
return e.RecurrenceId != nil
|
||||
}
|
||||
|
||||
// checks to see if the event is a recurrence override
|
||||
func (e *Event) IsOverride() bool {
|
||||
return e.IsRecurrence() && !e.RecurrenceId.Equals(e.DateStart)
|
||||
}
|
||||
|
||||
// creates a new iCalendar event with no end time
|
||||
func NewEvent(uid string, start time.Time) *Event {
|
||||
e := new(Event)
|
||||
e.UID = uid
|
||||
e.DateStamp = values.NewDateTime(time.Now().UTC())
|
||||
e.DateStart = values.NewDateTime(start)
|
||||
return e
|
||||
}
|
||||
|
||||
// creates a new iCalendar event that lasts a certain duration
|
||||
func NewEventWithDuration(uid string, start time.Time, duration time.Duration) *Event {
|
||||
e := NewEvent(uid, start)
|
||||
e.Duration = values.NewDuration(duration)
|
||||
return e
|
||||
}
|
||||
|
||||
// creates a new iCalendar event that has an explicit start and end time
|
||||
func NewEventWithEnd(uid string, start time.Time, end time.Time) *Event {
|
||||
e := NewEvent(uid, start)
|
||||
e.DateEnd = values.NewDateTime(end)
|
||||
return e
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/dolanor/caldav-go/icalendar/values"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TimeZone struct {
|
||||
|
||||
// defines the persistent, globally unique identifier for the calendar component.
|
||||
Id string `ical:"tzid,required"`
|
||||
|
||||
// the location name, as defined by the standards body
|
||||
ExtLocationName string `ical:"x-lic-location,omitempty"`
|
||||
|
||||
// defines a Uniform Resource Locator (URL) associated with the iCalendar object.
|
||||
Url *values.Url `ical:"tzurl,omitempty"`
|
||||
|
||||
// specifies the date and time that the information associated with the calendar component was last revised in the
|
||||
// calendar store.
|
||||
// Note: This is analogous to the modification date and time for a file in the file system.
|
||||
LastModified *values.DateTime `ical:"last-modified,omitempty"`
|
||||
|
||||
// TODO need to figure out how to handle standard and daylight savings time
|
||||
|
||||
}
|
||||
|
||||
func NewDynamicTimeZone(location *time.Location) *TimeZone {
|
||||
t := new(TimeZone)
|
||||
t.Id = location.String()
|
||||
t.ExtLocationName = location.String()
|
||||
t.Url = values.NewUrl(url.URL{
|
||||
Scheme: "http",
|
||||
Host: "tzurl.org",
|
||||
Path: fmt.Sprintf("/zoneinfo/%s", t.Id),
|
||||
})
|
||||
return t
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
package icalendar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/dolanor/caldav-go/icalendar/properties"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"log"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
Newline = "\r\n"
|
||||
)
|
||||
|
||||
var _ = log.Print
|
||||
|
||||
type encoder func(reflect.Value) (string, error)
|
||||
|
||||
func tagAndJoinValue(v reflect.Value, in []string) (string, error) {
|
||||
if tag, err := extractTagFromValue(v); err != nil {
|
||||
return "", utils.NewError(tagAndJoinValue, "unable to extract tag from value", v, err)
|
||||
} else {
|
||||
var out []string
|
||||
out = append(out, properties.MarshalProperty(properties.NewProperty("begin", tag)))
|
||||
out = append(out, in...)
|
||||
out = append(out, properties.MarshalProperty(properties.NewProperty("end", tag)))
|
||||
return strings.Join(out, Newline), nil
|
||||
}
|
||||
}
|
||||
|
||||
func marshalCollection(v reflect.Value) (string, error) {
|
||||
|
||||
var out []string
|
||||
|
||||
for i, n := 0, v.Len(); i < n; i++ {
|
||||
vi := v.Index(i).Interface()
|
||||
if encoded, err := Marshal(vi); err != nil {
|
||||
msg := fmt.Sprintf("unable to encode interface at index %d", i)
|
||||
return "", utils.NewError(marshalCollection, msg, vi, err)
|
||||
} else if encoded != "" {
|
||||
out = append(out, encoded)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(out, Newline), nil
|
||||
|
||||
}
|
||||
|
||||
func marshalStruct(v reflect.Value) (string, error) {
|
||||
|
||||
var out []string
|
||||
|
||||
// iterate over all fields
|
||||
vtype := v.Type()
|
||||
n := vtype.NumField()
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
|
||||
// keep a reference to the field value and definition
|
||||
fv := v.Field(i)
|
||||
fs := vtype.Field(i)
|
||||
|
||||
// use the field definition to extract out property defaults
|
||||
p := properties.PropertyFromStructField(fs)
|
||||
if p == nil {
|
||||
continue // skip explicitly ignored fields and private members
|
||||
}
|
||||
|
||||
fi := fv.Interface()
|
||||
|
||||
// some fields are not properties, but actually nested objects.
|
||||
// detect those early using the property and object encoder...
|
||||
if _, ok := fi.(properties.CanEncodeValue); !ok && !isInvalidOrEmptyValue(fv) {
|
||||
if encoded, err := encode(fv, objectEncoder); err != nil {
|
||||
msg := fmt.Sprintf("unable to encode field %s", fs.Name)
|
||||
return "", utils.NewError(marshalStruct, msg, v.Interface(), err)
|
||||
} else if encoded != "" {
|
||||
// encoding worked! no need to process as a property
|
||||
out = append(out, encoded)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// now check to see if the field value overrides the defaults...
|
||||
if !isInvalidOrEmptyValue(fv) {
|
||||
// first, check the field value interface for overrides...
|
||||
if overrides, err := properties.PropertyFromInterface(fi); err != nil {
|
||||
msg := fmt.Sprintf("field %s failed validation", fs.Name)
|
||||
return "", utils.NewError(marshalStruct, msg, v.Interface(), err)
|
||||
} else if p.Merge(overrides); p.Value == "" {
|
||||
// then, if we couldn't find an override from the interface,
|
||||
// try the simple string encoder...
|
||||
if p.Value, err = stringEncoder(fv); err != nil {
|
||||
msg := fmt.Sprintf("unable to encode field %s", fs.Name)
|
||||
return "", utils.NewError(marshalStruct, msg, v.Interface(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// make sure we have a value by this point
|
||||
if !p.HasNameAndValue() {
|
||||
if p.OmitEmpty {
|
||||
continue
|
||||
} else if p.DefaultValue != "" {
|
||||
p.Value = p.DefaultValue
|
||||
} else if p.Required {
|
||||
msg := fmt.Sprintf("missing value for required field %s", fs.Name)
|
||||
return "", utils.NewError(Marshal, msg, v.Interface(), nil)
|
||||
}
|
||||
}
|
||||
|
||||
// encode in the property
|
||||
out = append(out, properties.MarshalProperty(p))
|
||||
|
||||
}
|
||||
|
||||
// wrap the fields in the enclosing struct tags
|
||||
return tagAndJoinValue(v, out)
|
||||
|
||||
}
|
||||
|
||||
func objectEncoder(v reflect.Value) (string, error) {
|
||||
|
||||
// decompose the value into its interface parts
|
||||
v = dereferencePointerValue(v)
|
||||
|
||||
// encode the value based off of its type
|
||||
switch v.Kind() {
|
||||
case reflect.Slice:
|
||||
fallthrough
|
||||
case reflect.Array:
|
||||
return marshalCollection(v)
|
||||
case reflect.Struct:
|
||||
return marshalStruct(v)
|
||||
}
|
||||
|
||||
return "", nil
|
||||
|
||||
}
|
||||
|
||||
func stringEncoder(v reflect.Value) (string, error) {
|
||||
return fmt.Sprintf("%v", v.Interface()), nil
|
||||
}
|
||||
|
||||
func propertyEncoder(v reflect.Value) (string, error) {
|
||||
|
||||
vi := v.Interface()
|
||||
if p, err := properties.PropertyFromInterface(vi); err != nil {
|
||||
|
||||
// return early if interface fails its own validation
|
||||
return "", err
|
||||
|
||||
} else if p.HasNameAndValue() {
|
||||
|
||||
// if an interface encodes its own name and value, it's a property
|
||||
return properties.MarshalProperty(p), nil
|
||||
|
||||
}
|
||||
|
||||
return "", nil
|
||||
|
||||
}
|
||||
|
||||
func encode(v reflect.Value, encoders ...encoder) (string, error) {
|
||||
|
||||
for _, encode := range encoders {
|
||||
if encoded, err := encode(v); err != nil {
|
||||
return "", err
|
||||
} else if encoded != "" {
|
||||
return encoded, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
|
||||
}
|
||||
|
||||
// converts an iCalendar component into its string representation
|
||||
func Marshal(target interface{}) (string, error) {
|
||||
|
||||
// don't do anything with invalid interfaces
|
||||
v := reflect.ValueOf(target)
|
||||
if isInvalidOrEmptyValue(v) {
|
||||
return "", utils.NewError(Marshal, "unable to marshal empty or invalid values", target, nil)
|
||||
}
|
||||
|
||||
if encoded, err := encode(v, propertyEncoder, objectEncoder, stringEncoder); err != nil {
|
||||
return "", err
|
||||
} else if encoded == "" {
|
||||
return "", utils.NewError(Marshal, "unable to encode interface, all methods exhausted", v.Interface(), nil)
|
||||
} else {
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package properties
|
||||
|
||||
type CanValidateValue interface {
|
||||
ValidateICalValue() error
|
||||
}
|
||||
|
||||
type CanDecodeValue interface {
|
||||
DecodeICalValue(string) error
|
||||
}
|
||||
|
||||
type CanDecodeParams interface {
|
||||
DecodeICalParams(Params) error
|
||||
}
|
||||
|
||||
type CanEncodeTag interface {
|
||||
EncodeICalTag() (string, error)
|
||||
}
|
||||
|
||||
type CanEncodeValue interface {
|
||||
EncodeICalValue() (string, error)
|
||||
}
|
||||
|
||||
type CanEncodeName interface {
|
||||
EncodeICalName() (PropertyName, error)
|
||||
}
|
||||
|
||||
type CanEncodeParams interface {
|
||||
EncodeICalParams() (Params, error)
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package properties
|
||||
|
||||
import "strings"
|
||||
|
||||
type PropertyName string
|
||||
|
||||
const (
|
||||
UIDPropertyName PropertyName = "UID"
|
||||
CommentPropertyName = "COMMENT"
|
||||
OrganizerPropertyName = "ORGANIZER"
|
||||
AttendeePropertyName = "ATTENDEE"
|
||||
ExceptionDateTimesPropertyName = "EXDATE"
|
||||
RecurrenceDateTimesPropertyName = "RDATE"
|
||||
RecurrenceRulePropertyName = "RRULE"
|
||||
LocationPropertyName = "LOCATION"
|
||||
)
|
||||
|
||||
type ParameterName string
|
||||
|
||||
const (
|
||||
CanonicalNameParameterName ParameterName = "CN"
|
||||
TimeZoneIdPropertyName = "TZID"
|
||||
ValuePropertyName = "VALUE"
|
||||
AlternateRepresentationName = "ALTREP"
|
||||
)
|
||||
|
||||
type Params map[ParameterName]string
|
||||
|
||||
func (p PropertyName) Equals(test string) bool {
|
||||
return strings.EqualFold(string(p), test)
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
package properties
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"log"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var _ = log.Print
|
||||
|
||||
var propNameSanitizer = strings.NewReplacer(
|
||||
"_", "-",
|
||||
":", "\\:",
|
||||
)
|
||||
|
||||
var propValueSanitizer = strings.NewReplacer(
|
||||
"\"", "'",
|
||||
"\\", "\\\\",
|
||||
"\n", "\\n",
|
||||
)
|
||||
|
||||
var propNameDesanitizer = strings.NewReplacer(
|
||||
"-", "_",
|
||||
"\\:", ":",
|
||||
)
|
||||
|
||||
var propValueDesanitizer = strings.NewReplacer(
|
||||
"'", "\"",
|
||||
"\\\\", "\\",
|
||||
"\\n", "\n",
|
||||
)
|
||||
|
||||
type Property struct {
|
||||
Name PropertyName
|
||||
Value, DefaultValue string
|
||||
Params Params
|
||||
OmitEmpty, Required bool
|
||||
}
|
||||
|
||||
func (p *Property) HasNameAndValue() bool {
|
||||
return p.Name != "" && p.Value != ""
|
||||
}
|
||||
|
||||
func (p *Property) Merge(override *Property) {
|
||||
if override.Name != "" {
|
||||
p.Name = override.Name
|
||||
}
|
||||
if override.Value != "" {
|
||||
p.Value = override.Value
|
||||
}
|
||||
if override.Params != nil {
|
||||
p.Params = override.Params
|
||||
}
|
||||
}
|
||||
|
||||
func PropertyFromStructField(fs reflect.StructField) (p *Property) {
|
||||
|
||||
ftag := fs.Tag.Get("ical")
|
||||
if fs.PkgPath != "" || ftag == "-" {
|
||||
return
|
||||
}
|
||||
|
||||
p = new(Property)
|
||||
|
||||
// parse the field tag
|
||||
if ftag != "" {
|
||||
tags := strings.Split(ftag, ",")
|
||||
p.Name = PropertyName(tags[0])
|
||||
if len(tags) > 1 {
|
||||
if tags[1] == "omitempty" {
|
||||
p.OmitEmpty = true
|
||||
} else if tags[1] == "required" {
|
||||
p.Required = true
|
||||
} else {
|
||||
p.DefaultValue = tags[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// make sure we have a name
|
||||
if p.Name == "" {
|
||||
p.Name = PropertyName(fs.Name)
|
||||
}
|
||||
|
||||
p.Name = PropertyName(strings.ToUpper(string(p.Name)))
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
func MarshalProperty(p *Property) string {
|
||||
name := strings.ToUpper(propNameSanitizer.Replace(string(p.Name)))
|
||||
value := propValueSanitizer.Replace(p.Value)
|
||||
keys := []string{name}
|
||||
for name, value := range p.Params {
|
||||
name = ParameterName(strings.ToUpper(propNameSanitizer.Replace(string(name))))
|
||||
value = propValueSanitizer.Replace(value)
|
||||
if strings.ContainsAny(value, " :") {
|
||||
keys = append(keys, fmt.Sprintf("%s=\"%s\"", name, value))
|
||||
} else {
|
||||
keys = append(keys, fmt.Sprintf("%s=%s", name, value))
|
||||
}
|
||||
}
|
||||
name = strings.Join(keys, ";")
|
||||
return fmt.Sprintf("%s:%s", name, value)
|
||||
}
|
||||
|
||||
func PropertyFromInterface(target interface{}) (p *Property, err error) {
|
||||
|
||||
var ierr error
|
||||
if va, ok := target.(CanValidateValue); ok {
|
||||
if ierr = va.ValidateICalValue(); ierr != nil {
|
||||
err = utils.NewError(PropertyFromInterface, "interface failed validation", target, ierr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
p = new(Property)
|
||||
|
||||
if enc, ok := target.(CanEncodeName); ok {
|
||||
if p.Name, ierr = enc.EncodeICalName(); ierr != nil {
|
||||
err = utils.NewError(PropertyFromInterface, "interface failed name encoding", target, ierr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if enc, ok := target.(CanEncodeParams); ok {
|
||||
if p.Params, ierr = enc.EncodeICalParams(); ierr != nil {
|
||||
err = utils.NewError(PropertyFromInterface, "interface failed params encoding", target, ierr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if enc, ok := target.(CanEncodeValue); ok {
|
||||
if p.Value, ierr = enc.EncodeICalValue(); ierr != nil {
|
||||
err = utils.NewError(PropertyFromInterface, "interface failed value encoding", target, ierr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
func UnmarshalProperty(line string) *Property {
|
||||
nvp := strings.SplitN(line, ":", 2)
|
||||
prop := new(Property)
|
||||
if len(nvp) > 1 {
|
||||
prop.Value = strings.TrimSpace(nvp[1])
|
||||
}
|
||||
npp := strings.Split(nvp[0], ";")
|
||||
if len(npp) > 1 {
|
||||
prop.Params = make(map[ParameterName]string, 0)
|
||||
for i := 1; i < len(npp); i++ {
|
||||
var key, value string
|
||||
kvp := strings.Split(npp[i], "=")
|
||||
key = strings.TrimSpace(kvp[0])
|
||||
key = propNameDesanitizer.Replace(key)
|
||||
if len(kvp) > 1 {
|
||||
value = strings.TrimSpace(kvp[1])
|
||||
value = propValueDesanitizer.Replace(value)
|
||||
value = strings.Trim(value, "\"")
|
||||
}
|
||||
prop.Params[ParameterName(key)] = value
|
||||
}
|
||||
}
|
||||
prop.Name = PropertyName(strings.TrimSpace(npp[0]))
|
||||
prop.Name = PropertyName(propNameDesanitizer.Replace(string(prop.Name)))
|
||||
prop.Value = propValueDesanitizer.Replace(prop.Value)
|
||||
return prop
|
||||
}
|
||||
|
||||
func NewProperty(name, value string) *Property {
|
||||
return &Property{Name: PropertyName(name), Value: value}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package icalendar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/dolanor/caldav-go/icalendar/properties"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"log"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var _ = log.Print
|
||||
|
||||
func isInvalidOrEmptyValue(v reflect.Value) bool {
|
||||
if !v.IsValid() {
|
||||
return true
|
||||
}
|
||||
switch v.Kind() {
|
||||
case reflect.Array, reflect.Map, reflect.Slice, reflect.String:
|
||||
return v.Len() == 0
|
||||
case reflect.Bool:
|
||||
return !v.Bool()
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return v.Int() == 0
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
return v.Uint() == 0
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return v.Float() == 0
|
||||
case reflect.Interface, reflect.Ptr:
|
||||
return v.IsNil()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newValue(in reflect.Value) (out reflect.Value, isArrayElement bool) {
|
||||
|
||||
typ := in.Type()
|
||||
kind := typ.Kind()
|
||||
|
||||
for {
|
||||
if kind == reflect.Array || kind == reflect.Slice {
|
||||
isArrayElement = true
|
||||
} else if kind != reflect.Ptr {
|
||||
break
|
||||
}
|
||||
typ = typ.Elem()
|
||||
kind = typ.Kind()
|
||||
}
|
||||
|
||||
out = reflect.New(typ)
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
func dereferencePointerValue(v reflect.Value) reflect.Value {
|
||||
for (v.Kind() == reflect.Interface || v.Kind() == reflect.Ptr) && v.Elem().IsValid() {
|
||||
v = v.Elem()
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func extractTagFromValue(v reflect.Value) (string, error) {
|
||||
|
||||
vdref := dereferencePointerValue(v)
|
||||
vtemp, _ := newValue(vdref)
|
||||
|
||||
if encoder, ok := vtemp.Interface().(properties.CanEncodeTag); ok {
|
||||
if tag, err := encoder.EncodeICalTag(); err != nil {
|
||||
return "", utils.NewError(extractTagFromValue, "unable to extract tag from interface", v.Interface(), err)
|
||||
} else {
|
||||
return strings.ToUpper(tag), nil
|
||||
}
|
||||
} else {
|
||||
typ := vtemp.Elem().Type()
|
||||
return strings.ToUpper(fmt.Sprintf("v%s", typ.Name())), nil
|
||||
}
|
||||
|
||||
}
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
package icalendar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/dolanor/caldav-go/icalendar/properties"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"log"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var _ = log.Print
|
||||
var splitter = regexp.MustCompile("\r?\n")
|
||||
|
||||
type token struct {
|
||||
name string
|
||||
components map[string][]*token
|
||||
properties map[properties.PropertyName][]*properties.Property
|
||||
}
|
||||
|
||||
func tokenize(encoded string) (*token, error) {
|
||||
if encoded = strings.TrimSpace(encoded); encoded == "" {
|
||||
return nil, utils.NewError(tokenize, "no content to tokenize", encoded, nil)
|
||||
}
|
||||
return tokenizeSlice(splitter.Split(encoded, -1))
|
||||
}
|
||||
|
||||
func tokenizeSlice(slice []string, name ...string) (*token, error) {
|
||||
|
||||
tok := new(token)
|
||||
size := len(slice)
|
||||
|
||||
if len(name) > 0 {
|
||||
tok.name = name[0]
|
||||
} else if size <= 0 {
|
||||
return nil, utils.NewError(tokenizeSlice, "token has no content", slice, nil)
|
||||
}
|
||||
|
||||
tok.properties = make(map[properties.PropertyName][]*properties.Property, 0)
|
||||
tok.components = make(map[string][]*token, 0)
|
||||
|
||||
for i := 0; i < size; i++ {
|
||||
|
||||
// Handle iCalendar's space-indented line break format
|
||||
// See: https://www.ietf.org/rfc/rfc2445.txt section 4.1
|
||||
// "a long line can be split between any two characters by inserting a CRLF immediately followed by a single
|
||||
// linear white space character"
|
||||
line := slice[i]
|
||||
for ; i < size-1 && strings.HasPrefix(slice[i+1], " "); i++ {
|
||||
next := slice[i+1]
|
||||
line += next[1:len(next)]
|
||||
}
|
||||
|
||||
prop := properties.UnmarshalProperty(line)
|
||||
|
||||
if prop.Name.Equals("begin") {
|
||||
for j := i; j < size; j++ {
|
||||
end := strings.Replace(line, "BEGIN", "END", 1)
|
||||
if slice[j] == end {
|
||||
if component, err := tokenizeSlice(slice[i+1:j], prop.Value); err != nil {
|
||||
msg := fmt.Sprintf("unable to tokenize %s component", prop.Value)
|
||||
return nil, utils.NewError(tokenizeSlice, msg, slice, err)
|
||||
} else {
|
||||
existing, _ := tok.components[prop.Value]
|
||||
tok.components[prop.Value] = append(existing, component)
|
||||
i = j
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if existing, ok := tok.properties[prop.Name]; ok {
|
||||
tok.properties[prop.Name] = []*properties.Property{prop}
|
||||
} else {
|
||||
tok.properties[prop.Name] = append(existing, prop)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return tok, nil
|
||||
|
||||
}
|
||||
|
||||
func hydrateInterface(v reflect.Value, prop *properties.Property) (bool, error) {
|
||||
|
||||
// unable to decode into empty values
|
||||
if isInvalidOrEmptyValue(v) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var i = v.Interface()
|
||||
var hasValue = false
|
||||
|
||||
// decode a value if possible
|
||||
if decoder, ok := i.(properties.CanDecodeValue); ok {
|
||||
if err := decoder.DecodeICalValue(prop.Value); err != nil {
|
||||
return false, utils.NewError(hydrateInterface, "error decoding property value", v, err)
|
||||
} else {
|
||||
hasValue = true
|
||||
}
|
||||
}
|
||||
|
||||
// decode any params, if supported
|
||||
if len(prop.Params) > 0 {
|
||||
if decoder, ok := i.(properties.CanDecodeParams); ok {
|
||||
if err := decoder.DecodeICalParams(prop.Params); err != nil {
|
||||
return false, utils.NewError(hydrateInterface, "error decoding property parameters", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// finish with any validation
|
||||
if validator, ok := i.(properties.CanValidateValue); ok {
|
||||
if err := validator.ValidateICalValue(); err != nil {
|
||||
return false, utils.NewError(hydrateInterface, "error validating property value", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
return hasValue, nil
|
||||
|
||||
}
|
||||
|
||||
func hydrateLiteral(v reflect.Value, prop *properties.Property) (reflect.Value, error) {
|
||||
|
||||
literal := dereferencePointerValue(v)
|
||||
|
||||
switch literal.Kind() {
|
||||
case reflect.Bool:
|
||||
if i, err := strconv.ParseBool(prop.Value); err != nil {
|
||||
return literal, utils.NewError(hydrateLiteral, "unable to decode bool "+prop.Value, literal.Interface(), err)
|
||||
} else {
|
||||
literal.SetBool(i)
|
||||
}
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
if i, err := strconv.ParseInt(prop.Value, 10, 64); err != nil {
|
||||
return literal, utils.NewError(hydrateLiteral, "unable to decode int "+prop.Value, literal.Interface(), err)
|
||||
} else {
|
||||
literal.SetInt(i)
|
||||
}
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
if i, err := strconv.ParseUint(prop.Value, 10, 64); err != nil {
|
||||
return literal, utils.NewError(hydrateLiteral, "unable to decode uint "+prop.Value, literal.Interface(), err)
|
||||
} else {
|
||||
literal.SetUint(i)
|
||||
}
|
||||
case reflect.Float32, reflect.Float64:
|
||||
if i, err := strconv.ParseFloat(prop.Value, 64); err != nil {
|
||||
return literal, utils.NewError(hydrateLiteral, "unable to decode float "+prop.Value, literal.Interface(), err)
|
||||
} else {
|
||||
literal.SetFloat(i)
|
||||
}
|
||||
case reflect.String:
|
||||
literal.SetString(prop.Value)
|
||||
default:
|
||||
return literal, utils.NewError(hydrateLiteral, "unable to decode value as literal "+prop.Value, literal.Interface(), nil)
|
||||
}
|
||||
|
||||
return literal, nil
|
||||
|
||||
}
|
||||
|
||||
func hydrateProperty(v reflect.Value, prop *properties.Property) error {
|
||||
|
||||
// check to see if the interface handles it's own hydration
|
||||
if handled, err := hydrateInterface(v, prop); err != nil {
|
||||
return utils.NewError(hydrateProperty, "unable to hydrate interface", v, err)
|
||||
} else if handled {
|
||||
return nil // exit early if handled by the interface
|
||||
}
|
||||
|
||||
// if we got here, we need to create a new instance to
|
||||
// set into the property.
|
||||
var vnew, varr = newValue(v)
|
||||
var vlit bool
|
||||
|
||||
// check to see if the new value handles it's own hydration
|
||||
if handled, err := hydrateInterface(vnew, prop); err != nil {
|
||||
return utils.NewError(hydrateProperty, "unable to hydrate new interface value", vnew, err)
|
||||
} else if vlit = !handled; vlit {
|
||||
// if not, treat it as a literal
|
||||
if vnewlit, err := hydrateLiteral(vnew, prop); err != nil {
|
||||
return utils.NewError(hydrateProperty, "unable to hydrate new literal value", vnew, err)
|
||||
} else if _, err := hydrateInterface(vnewlit, prop); err != nil {
|
||||
return utils.NewError(hydrateProperty, "unable to hydrate new literal interface value", vnewlit, err)
|
||||
}
|
||||
}
|
||||
|
||||
// now we can set the value
|
||||
vnewval := dereferencePointerValue(vnew)
|
||||
voldval := dereferencePointerValue(v)
|
||||
|
||||
// make sure we can set the new value into the provided pointer
|
||||
|
||||
if varr {
|
||||
// for arrays, append the new value into the array structure
|
||||
if !voldval.CanSet() {
|
||||
return utils.NewError(hydrateProperty, "unable to set array value", v, nil)
|
||||
} else {
|
||||
voldval.Set(reflect.Append(voldval, vnewval))
|
||||
}
|
||||
} else if vlit {
|
||||
// for literals, set the dereferenced value
|
||||
if !voldval.CanSet() {
|
||||
return utils.NewError(hydrateProperty, "unable to set literal value", v, nil)
|
||||
} else {
|
||||
voldval.Set(vnewval)
|
||||
}
|
||||
} else if !v.CanSet() {
|
||||
return utils.NewError(hydrateProperty, "unable to set pointer value", v, nil)
|
||||
} else {
|
||||
// everything else should be a pointer, set it directly
|
||||
v.Set(vnew)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func hydrateNestedComponent(v reflect.Value, component *token) error {
|
||||
|
||||
// create a new object to hold the property value
|
||||
var vnew, varr = newValue(v)
|
||||
if err := hydrateComponent(vnew, component); err != nil {
|
||||
return utils.NewError(hydrateNestedComponent, "unable to decode component", component, err)
|
||||
}
|
||||
|
||||
if varr {
|
||||
// for arrays, append the new value into the array structure
|
||||
voldval := dereferencePointerValue(v)
|
||||
if !voldval.CanSet() {
|
||||
return utils.NewError(hydrateNestedComponent, "unable to set array value", v, nil)
|
||||
} else {
|
||||
voldval.Set(reflect.Append(voldval, vnew))
|
||||
}
|
||||
} else if !v.CanSet() {
|
||||
return utils.NewError(hydrateNestedComponent, "unable to set pointer value", v, nil)
|
||||
} else {
|
||||
// everything else should be a pointer, set it directly
|
||||
v.Set(vnew)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func hydrateProperties(v reflect.Value, component *token) error {
|
||||
|
||||
vdref := dereferencePointerValue(v)
|
||||
vtype := vdref.Type()
|
||||
vkind := vdref.Kind()
|
||||
|
||||
if vkind != reflect.Struct {
|
||||
return utils.NewError(hydrateProperties, "unable to hydrate properties of non-struct", v, nil)
|
||||
}
|
||||
|
||||
n := vtype.NumField()
|
||||
for i := 0; i < n; i++ {
|
||||
|
||||
prop := properties.PropertyFromStructField(vtype.Field(i))
|
||||
if prop == nil {
|
||||
continue // skip if field is ignored
|
||||
}
|
||||
|
||||
vfield := vdref.Field(i)
|
||||
|
||||
// first try to hydrate property values
|
||||
if properties, ok := component.properties[prop.Name]; ok {
|
||||
for _, prop := range properties {
|
||||
if err := hydrateProperty(vfield, prop); err != nil {
|
||||
msg := fmt.Sprintf("unable to hydrate property %s", prop.Name)
|
||||
return utils.NewError(hydrateProperties, msg, v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// then try to hydrate components
|
||||
vtemp, _ := newValue(vfield)
|
||||
if tag, err := extractTagFromValue(vtemp); err != nil {
|
||||
msg := fmt.Sprintf("unable to extract tag from property %s", prop.Name)
|
||||
return utils.NewError(hydrateProperties, msg, v, err)
|
||||
} else if components, ok := component.components[tag]; ok {
|
||||
for _, comp := range components {
|
||||
if err := hydrateNestedComponent(vfield, comp); err != nil {
|
||||
msg := fmt.Sprintf("unable to hydrate component %s", prop.Name)
|
||||
return utils.NewError(hydrateProperties, msg, v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func hydrateComponent(v reflect.Value, component *token) error {
|
||||
if tag, err := extractTagFromValue(v); err != nil {
|
||||
return utils.NewError(hydrateComponent, "error extracting tag from value", component, err)
|
||||
} else if tag != component.name {
|
||||
msg := fmt.Sprintf("expected %s and found %s", tag, component.name)
|
||||
return utils.NewError(hydrateComponent, msg, component, nil)
|
||||
} else if err := hydrateProperties(v, component); err != nil {
|
||||
return utils.NewError(hydrateComponent, "unable to hydrate properties", component, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hydrateComponents(v reflect.Value, components []*token) error {
|
||||
vdref := dereferencePointerValue(v)
|
||||
for i, component := range components {
|
||||
velem := reflect.New(vdref.Type().Elem())
|
||||
if err := hydrateComponent(velem, component); err != nil {
|
||||
msg := fmt.Sprintf("unable to hydrate component %d", i)
|
||||
return utils.NewError(hydrateComponent, msg, component, err)
|
||||
} else {
|
||||
v.Set(reflect.Append(vdref, velem))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hydrateValue(v reflect.Value, component *token) error {
|
||||
|
||||
if !v.IsValid() || v.Kind() != reflect.Ptr {
|
||||
return utils.NewError(hydrateValue, "unmarshal target must be a valid pointer", v, nil)
|
||||
}
|
||||
|
||||
// handle any encodable properties
|
||||
if encoder, isprop := v.Interface().(properties.CanEncodeName); isprop {
|
||||
if name, err := encoder.EncodeICalName(); err != nil {
|
||||
return utils.NewError(hydrateValue, "unable to lookup property name", v, err)
|
||||
} else if properties, found := component.properties[name]; !found || len(properties) == 0 {
|
||||
return utils.NewError(hydrateValue, "no matching propery values found for "+string(name), v, nil)
|
||||
} else if len(properties) > 1 {
|
||||
return utils.NewError(hydrateValue, "more than one property value matches single property interface", v, nil)
|
||||
} else {
|
||||
return hydrateProperty(v, properties[0])
|
||||
}
|
||||
}
|
||||
|
||||
// handle components
|
||||
vkind := dereferencePointerValue(v).Kind()
|
||||
if tag, err := extractTagFromValue(v); err != nil {
|
||||
return utils.NewError(hydrateValue, "unable to extract component tag", v, err)
|
||||
} else if components, found := component.components[tag]; !found || len(components) == 0 {
|
||||
msg := fmt.Sprintf("unable to find matching component for %s", tag)
|
||||
return utils.NewError(hydrateValue, msg, v, nil)
|
||||
} else if vkind == reflect.Array || vkind == reflect.Slice {
|
||||
return hydrateComponents(v, components)
|
||||
} else if len(components) > 1 {
|
||||
return utils.NewError(hydrateValue, "non-array interface provided but more than one component found!", v, nil)
|
||||
} else {
|
||||
return hydrateComponent(v, components[0])
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// decodes encoded icalendar data into a native interface
|
||||
func Unmarshal(encoded string, into interface{}) error {
|
||||
if component, err := tokenize(encoded); err != nil {
|
||||
return utils.NewError(Unmarshal, "unable to tokenize encoded data", encoded, err)
|
||||
} else {
|
||||
return hydrateValue(reflect.ValueOf(into), component)
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package values
|
||||
|
||||
type CalScale string
|
||||
|
||||
const (
|
||||
GregorianCalScale CalScale = "GREGORIAN"
|
||||
)
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"github.com/dolanor/caldav-go/icalendar/properties"
|
||||
)
|
||||
|
||||
// specifies non-processing information intended to provide a comment to the calendar user.
|
||||
type Comment string
|
||||
|
||||
// encodes the comment value for the iCalendar specification
|
||||
func (c Comment) EncodeICalValue() (string, error) {
|
||||
return string(c), nil
|
||||
}
|
||||
|
||||
// decodes the comment value from the iCalendar specification
|
||||
func (c Comment) DecodeICalValue(value string) error {
|
||||
c = Comment(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodes the comment value for the iCalendar specification
|
||||
func (c Comment) EncodeICalName() (properties.PropertyName, error) {
|
||||
return properties.CommentPropertyName, nil
|
||||
}
|
||||
|
||||
// creates a list of comments from strings
|
||||
func NewComments(comments ...string) []Comment {
|
||||
var _comments []Comment
|
||||
for _, comment := range comments {
|
||||
_comments = append(_comments, Comment(comment))
|
||||
}
|
||||
return _comments
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/dolanor/caldav-go/icalendar/properties"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"log"
|
||||
"net/mail"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var _ = log.Print
|
||||
|
||||
// Specifies the organizer of a group scheduled calendar entity. The property is specified within the "VFREEBUSY"
|
||||
// calendar component to specify the calendar user requesting the free or busy time. When publishing a "VFREEBUSY"
|
||||
// calendar component, the property is used to specify the calendar that the published busy time came from.
|
||||
//
|
||||
// The property has the property parameters CN, for specifying the common or display name associated with the
|
||||
// "Organizer", DIR, for specifying a pointer to the directory information associated with the "Organizer",
|
||||
// SENT-BY, for specifying another calendar user that is acting on behalf of the "Organizer". The non-standard
|
||||
// parameters may also be specified on this property. If the LANGUAGE property parameter is specified, the identified
|
||||
// language applies to the CN parameter value.
|
||||
type Contact struct {
|
||||
Entry mail.Address
|
||||
}
|
||||
|
||||
type AttendeeContact Contact
|
||||
type OrganizerContact Contact
|
||||
|
||||
// creates a new icalendar attendee representation
|
||||
func NewAttendeeContact(name, email string) *AttendeeContact {
|
||||
return &AttendeeContact{Entry: mail.Address{Name: name, Address: email}}
|
||||
}
|
||||
|
||||
// creates a new icalendar organizer representation
|
||||
func NewOrganizerContact(name, email string) *OrganizerContact {
|
||||
return &OrganizerContact{Entry: mail.Address{Name: name, Address: email}}
|
||||
}
|
||||
|
||||
// validates the contact value for the iCalendar specification
|
||||
func (c *Contact) ValidateICalValue() error {
|
||||
email := c.Entry.String()
|
||||
if _, err := mail.ParseAddress(email); err != nil {
|
||||
msg := fmt.Sprintf("unable to validate address %s", email)
|
||||
return utils.NewError(c.ValidateICalValue, msg, c, err)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// encodes the contact value for the iCalendar specification
|
||||
func (c *Contact) EncodeICalValue() (string, error) {
|
||||
return fmt.Sprintf("MAILTO:%s", c.Entry.Address), nil
|
||||
}
|
||||
|
||||
// encodes the contact params for the iCalendar specification
|
||||
func (c *Contact) EncodeICalParams() (params properties.Params, err error) {
|
||||
if c.Entry.Name != "" {
|
||||
params = properties.Params{properties.CanonicalNameParameterName: c.Entry.Name}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// decodes the contact value from the iCalendar specification
|
||||
func (c *Contact) DecodeICalValue(value string) error {
|
||||
parts := strings.SplitN(value, ":", 2)
|
||||
if len(parts) > 1 {
|
||||
c.Entry.Address = parts[1]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodes the contact params from the iCalendar specification
|
||||
func (c *Contact) DecodeICalParams(params properties.Params) error {
|
||||
if name, found := params[properties.CanonicalNameParameterName]; found {
|
||||
c.Entry.Name = name
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validates the contact value for the iCalendar specification
|
||||
func (c *OrganizerContact) ValidateICalValue() error {
|
||||
return (*Contact)(c).ValidateICalValue()
|
||||
}
|
||||
|
||||
// encodes the contact value for the iCalendar specification
|
||||
func (c *OrganizerContact) EncodeICalValue() (string, error) {
|
||||
return (*Contact)(c).EncodeICalValue()
|
||||
}
|
||||
|
||||
// encodes the contact params for the iCalendar specification
|
||||
func (c *OrganizerContact) EncodeICalParams() (params properties.Params, err error) {
|
||||
return (*Contact)(c).EncodeICalParams()
|
||||
}
|
||||
|
||||
// decodes the contact value from the iCalendar specification
|
||||
func (c *OrganizerContact) DecodeICalValue(value string) error {
|
||||
return (*Contact)(c).DecodeICalValue(value)
|
||||
}
|
||||
|
||||
// decodes the contact params from the iCalendar specification
|
||||
func (c *OrganizerContact) DecodeICalParams(params properties.Params) error {
|
||||
return (*Contact)(c).DecodeICalParams(params)
|
||||
}
|
||||
|
||||
// encodes the contact property name for the iCalendar specification
|
||||
func (o *OrganizerContact) EncodeICalName() (properties.PropertyName, error) {
|
||||
return properties.OrganizerPropertyName, nil
|
||||
}
|
||||
|
||||
// validates the contact value for the iCalendar specification
|
||||
func (c *AttendeeContact) ValidateICalValue() error {
|
||||
return (*Contact)(c).ValidateICalValue()
|
||||
}
|
||||
|
||||
// encodes the contact value for the iCalendar specification
|
||||
func (c *AttendeeContact) EncodeICalValue() (string, error) {
|
||||
return (*Contact)(c).EncodeICalValue()
|
||||
}
|
||||
|
||||
// encodes the contact params for the iCalendar specification
|
||||
func (c *AttendeeContact) EncodeICalParams() (params properties.Params, err error) {
|
||||
return (*Contact)(c).EncodeICalParams()
|
||||
}
|
||||
|
||||
// decodes the contact value from the iCalendar specification
|
||||
func (c *AttendeeContact) DecodeICalValue(value string) error {
|
||||
return (*Contact)(c).DecodeICalValue(value)
|
||||
}
|
||||
|
||||
// decodes the contact params from the iCalendar specification
|
||||
func (c *AttendeeContact) DecodeICalParams(params properties.Params) error {
|
||||
return (*Contact)(c).DecodeICalParams(params)
|
||||
}
|
||||
|
||||
// encodes the contact property name for the iCalendar specification
|
||||
func (o *AttendeeContact) EncodeICalName() (properties.PropertyName, error) {
|
||||
return properties.AttendeePropertyName, nil
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var _ = log.Print
|
||||
|
||||
type CSV []string
|
||||
|
||||
func (csv *CSV) EncodeICalValue() (string, error) {
|
||||
return strings.Join(*csv, ","), nil
|
||||
}
|
||||
|
||||
func (csv *CSV) DecodeICalValue(value string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
*csv = CSV(strings.Split(value, ","))
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewCSV(items ...string) *CSV {
|
||||
return (*CSV)(&items)
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/dolanor/caldav-go/icalendar/properties"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var _ = log.Print
|
||||
|
||||
const DateFormatString = "20060102"
|
||||
const DateTimeFormatString = "20060102T150405"
|
||||
const UTCDateTimeFormatString = "20060102T150405Z"
|
||||
|
||||
// a representation of a date and time for iCalendar
|
||||
type DateTime struct {
|
||||
t time.Time
|
||||
}
|
||||
|
||||
type DateTimes []*DateTime
|
||||
|
||||
// The exception dates, if specified, are used in computing the recurrence set. The recurrence set is the complete set
|
||||
// of recurrence instances for a calendar component. The recurrence set is generated by considering the initial
|
||||
// "DTSTART" property along with the "RRULE", "RDATE", "EXDATE" and "EXRULE" properties contained within the iCalendar
|
||||
// object. The "DTSTART" property defines the first instance in the recurrence set. Multiple instances of the "RRULE"
|
||||
// and "EXRULE" properties can also be specified to define more sophisticated recurrence sets. The final recurrence set
|
||||
// is generated by gathering all of the start date-times generated by any of the specified "RRULE" and "RDATE"
|
||||
// properties, and then excluding any start date and times which fall within the union of start date and times
|
||||
// generated by any specified "EXRULE" and "EXDATE" properties. This implies that start date and times within exclusion
|
||||
// related properties (i.e., "EXDATE" and "EXRULE") take precedence over those specified by inclusion properties
|
||||
// (i.e., "RDATE" and "RRULE"). Where duplicate instances are generated by the "RRULE" and "RDATE" properties, only
|
||||
// one recurrence is considered. Duplicate instances are ignored.
|
||||
//
|
||||
// The "EXDATE" property can be used to exclude the value specified in "DTSTART". However, in such cases the original
|
||||
// "DTSTART" date MUST still be maintained by the calendaring and scheduling system because the original "DTSTART"
|
||||
// value has inherent usage dependencies by other properties such as the "RECURRENCE-ID".
|
||||
type ExceptionDateTimes DateTimes
|
||||
|
||||
// The recurrence dates, if specified, are used in computing the recurrence set. The recurrence set is the complete set
|
||||
// of recurrence instances for a calendar component. The recurrence set is generated by considering the initial
|
||||
// "DTSTART" property along with the "RRULE", "RDATE", "EXDATE" and "EXRULE" properties contained within the iCalendar
|
||||
// object. The "DTSTART" property defines the first instance in the recurrence set. Multiple instances of the "RRULE"
|
||||
// and "EXRULE" properties can also be specified to define more sophisticated recurrence sets. The final recurrence set
|
||||
// is generated by gathering all of the start date-times generated by any of the specified "RRULE" and "RDATE"
|
||||
// properties, and then excluding any start date and times which fall within the union of start date and times
|
||||
// generated by any specified "EXRULE" and "EXDATE" properties. This implies that start date and times within exclusion
|
||||
// related properties (i.e., "EXDATE" and "EXRULE") take precedence over those specified by inclusion properties
|
||||
// (i.e., "RDATE" and "RRULE"). Where duplicate instances are generated by the "RRULE" and "RDATE" properties, only
|
||||
// one recurrence is considered. Duplicate instances are ignored.
|
||||
type RecurrenceDateTimes DateTimes
|
||||
|
||||
// creates a new icalendar datetime representation
|
||||
func NewDateTime(t time.Time) *DateTime {
|
||||
return &DateTime{t: t.Truncate(time.Second)}
|
||||
}
|
||||
|
||||
// creates a new icalendar datetime array representation
|
||||
func NewDateTimes(dates ...*DateTime) DateTimes {
|
||||
return DateTimes(dates)
|
||||
}
|
||||
|
||||
// creates a new icalendar datetime array representation
|
||||
func NewExceptionDateTimes(dates ...*DateTime) *ExceptionDateTimes {
|
||||
datetimes := NewDateTimes(dates...)
|
||||
return (*ExceptionDateTimes)(&datetimes)
|
||||
}
|
||||
|
||||
// creates a new icalendar datetime array representation
|
||||
func NewRecurrenceDateTimes(dates ...*DateTime) *RecurrenceDateTimes {
|
||||
datetimes := NewDateTimes(dates...)
|
||||
return (*RecurrenceDateTimes)(&datetimes)
|
||||
}
|
||||
|
||||
// checks to see if two datetimes are equal
|
||||
func (d *DateTime) Equals(test *DateTime) bool {
|
||||
return d.t.Equal(test.t)
|
||||
}
|
||||
|
||||
// returns the native time for the datetime object
|
||||
func (d *DateTime) NativeTime() time.Time {
|
||||
return d.t
|
||||
}
|
||||
|
||||
// encodes the datetime value for the iCalendar specification
|
||||
func (d *DateTime) EncodeICalValue() (string, error) {
|
||||
val := d.t.Format(DateTimeFormatString)
|
||||
loc := d.t.Location()
|
||||
if loc == time.UTC {
|
||||
val = fmt.Sprintf("%sZ", val)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// decodes the datetime value from the iCalendar specification
|
||||
func (d *DateTime) DecodeICalValue(value string) error {
|
||||
layout := DateTimeFormatString
|
||||
if strings.HasSuffix(value, "Z") {
|
||||
layout = UTCDateTimeFormatString
|
||||
} else if len(value) == 8 {
|
||||
layout = DateFormatString
|
||||
}
|
||||
var err error
|
||||
d.t, err = time.ParseInLocation(layout, value, time.UTC)
|
||||
if err != nil {
|
||||
return utils.NewError(d.DecodeICalValue, "unable to parse datetime value", d, err)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// encodes the datetime params for the iCalendar specification
|
||||
func (d *DateTime) EncodeICalParams() (params properties.Params, err error) {
|
||||
loc := d.t.Location()
|
||||
if loc != time.UTC {
|
||||
params = properties.Params{properties.TimeZoneIdPropertyName: loc.String()}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// decodes the datetime params from the iCalendar specification
|
||||
func (d *DateTime) DecodeICalParams(params properties.Params) error {
|
||||
layout := DateTimeFormatString
|
||||
value := d.t.Format(layout)
|
||||
if name, found := params[properties.TimeZoneIdPropertyName]; !found {
|
||||
return nil
|
||||
} else if loc, err := time.LoadLocation(name); err != nil {
|
||||
return utils.NewError(d.DecodeICalValue, "unable to parse timezone", d, err)
|
||||
} else if t, err := time.ParseInLocation(layout, value, loc); err != nil {
|
||||
return utils.NewError(d.DecodeICalValue, "unable to parse datetime value", d, err)
|
||||
} else {
|
||||
d.t = t
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// validates the datetime value against the iCalendar specification
|
||||
func (d *DateTime) ValidateICalValue() error {
|
||||
|
||||
loc := d.t.Location()
|
||||
|
||||
if loc == time.Local {
|
||||
msg := "DateTime location may not Local, please use UTC or explicit Location"
|
||||
return utils.NewError(d.ValidateICalValue, msg, d, nil)
|
||||
}
|
||||
|
||||
if loc.String() == "" {
|
||||
msg := "DateTime location must have a valid name"
|
||||
return utils.NewError(d.ValidateICalValue, msg, d, nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodes the datetime value for the iCalendar specification
|
||||
func (d *DateTime) String() string {
|
||||
if s, err := d.EncodeICalValue(); err != nil {
|
||||
panic(err)
|
||||
} else {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// encodes a list of datetime values for the iCalendar specification
|
||||
func (ds *DateTimes) EncodeICalValue() (string, error) {
|
||||
var csv CSV
|
||||
for i, d := range *ds {
|
||||
if s, err := d.EncodeICalValue(); err != nil {
|
||||
msg := fmt.Sprintf("unable to encode datetime at index %d", i)
|
||||
return "", utils.NewError(ds.EncodeICalValue, msg, ds, err)
|
||||
} else {
|
||||
csv = append(csv, s)
|
||||
}
|
||||
}
|
||||
return csv.EncodeICalValue()
|
||||
}
|
||||
|
||||
// encodes a list of datetime params for the iCalendar specification
|
||||
func (ds *DateTimes) EncodeICalParams() (params properties.Params, err error) {
|
||||
if len(*ds) > 0 {
|
||||
params, err = (*ds)[0].EncodeICalParams()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// decodes a list of datetime params from the iCalendar specification
|
||||
func (ds *DateTimes) DecodeICalParams(params properties.Params) error {
|
||||
for i, d := range *ds {
|
||||
if err := d.DecodeICalParams(params); err != nil {
|
||||
msg := fmt.Sprintf("unable to decode datetime params for index %d", i)
|
||||
return utils.NewError(ds.DecodeICalValue, msg, ds, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodes a list of datetime values for the iCalendar specification
|
||||
func (ds *DateTimes) DecodeICalValue(value string) error {
|
||||
csv := new(CSV)
|
||||
if err := csv.DecodeICalValue(value); err != nil {
|
||||
return utils.NewError(ds.DecodeICalValue, "unable to decode datetime list as CSV", ds, err)
|
||||
}
|
||||
for i, value := range *csv {
|
||||
d := new(DateTime)
|
||||
if err := d.DecodeICalValue(value); err != nil {
|
||||
msg := fmt.Sprintf("unable to decode datetime at index %d", i)
|
||||
return utils.NewError(ds.DecodeICalValue, msg, ds, err)
|
||||
} else {
|
||||
*ds = append(*ds, d)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodes exception date times property name for icalendar
|
||||
func (e *ExceptionDateTimes) EncodeICalName() (properties.PropertyName, error) {
|
||||
return properties.ExceptionDateTimesPropertyName, nil
|
||||
}
|
||||
|
||||
// encodes recurrence date times property name for icalendar
|
||||
func (r *RecurrenceDateTimes) EncodeICalName() (properties.PropertyName, error) {
|
||||
return properties.RecurrenceDateTimesPropertyName, nil
|
||||
}
|
||||
|
||||
// encodes exception date times property value for icalendar
|
||||
func (e *ExceptionDateTimes) EncodeICalValue() (string, error) {
|
||||
return (*DateTimes)(e).EncodeICalValue()
|
||||
}
|
||||
|
||||
// encodes recurrence date times property value for icalendar
|
||||
func (r *RecurrenceDateTimes) EncodeICalValue() (string, error) {
|
||||
return (*DateTimes)(r).EncodeICalValue()
|
||||
}
|
||||
|
||||
// decodes exception date times property value for icalendar
|
||||
func (e *ExceptionDateTimes) DecodeICalValue(value string) error {
|
||||
return (*DateTimes)(e).DecodeICalValue(value)
|
||||
}
|
||||
|
||||
// decodes recurrence date times property value for icalendar
|
||||
func (r *RecurrenceDateTimes) DecodeICalValue(value string) error {
|
||||
return (*DateTimes)(r).DecodeICalValue(value)
|
||||
}
|
||||
|
||||
// encodes exception date times property params for icalendar
|
||||
func (e *ExceptionDateTimes) EncodeICalParams() (params properties.Params, err error) {
|
||||
return (*DateTimes)(e).EncodeICalParams()
|
||||
}
|
||||
|
||||
// encodes recurrence date times property params for icalendar
|
||||
func (r *RecurrenceDateTimes) EncodeICalParams() (params properties.Params, err error) {
|
||||
return (*DateTimes)(r).EncodeICalParams()
|
||||
}
|
||||
|
||||
// encodes exception date times property params for icalendar
|
||||
func (e *ExceptionDateTimes) DecodeICalParams(params properties.Params) error {
|
||||
return (*DateTimes)(e).DecodeICalParams(params)
|
||||
}
|
||||
|
||||
// encodes recurrence date times property params for icalendar
|
||||
func (r *RecurrenceDateTimes) DecodeICalParams(params properties.Params) error {
|
||||
return (*DateTimes)(r).DecodeICalParams(params)
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"log"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var _ = log.Print
|
||||
|
||||
// a representation of duration for iCalendar
|
||||
type Duration struct {
|
||||
d time.Duration
|
||||
}
|
||||
|
||||
// breaks apart the duration into its component time parts
|
||||
func (d *Duration) Decompose() (weeks, days, hours, minutes, seconds int64) {
|
||||
|
||||
// chip away at this
|
||||
rem := time.Duration(math.Abs(float64(d.d)))
|
||||
|
||||
div := time.Hour * 24 * 7
|
||||
weeks = int64(rem / div)
|
||||
rem = rem % div
|
||||
div = div / 7
|
||||
days = int64(rem / div)
|
||||
rem = rem % div
|
||||
div = div / 24
|
||||
hours = int64(rem / div)
|
||||
rem = rem % div
|
||||
div = div / 60
|
||||
minutes = int64(rem / div)
|
||||
rem = rem % div
|
||||
div = div / 60
|
||||
seconds = int64(rem / div)
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
// returns the native golang duration
|
||||
func (d *Duration) NativeDuration() time.Duration {
|
||||
return d.d
|
||||
}
|
||||
|
||||
// returns true if the duration is negative
|
||||
func (d *Duration) IsPast() bool {
|
||||
return d.d < 0
|
||||
}
|
||||
|
||||
// encodes the duration of time into iCalendar format
|
||||
func (d *Duration) EncodeICalValue() (string, error) {
|
||||
var parts []string
|
||||
weeks, days, hours, minutes, seconds := d.Decompose()
|
||||
if d.IsPast() {
|
||||
parts = append(parts, "-")
|
||||
}
|
||||
parts = append(parts, "P")
|
||||
if weeks > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%dW", weeks))
|
||||
}
|
||||
if days > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%dD", days))
|
||||
}
|
||||
if hours > 0 || minutes > 0 || seconds > 0 {
|
||||
parts = append(parts, "T")
|
||||
if hours > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%dH", hours))
|
||||
}
|
||||
if minutes > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%dM", minutes))
|
||||
}
|
||||
if seconds > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%dS", seconds))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ""), nil
|
||||
}
|
||||
|
||||
var durationRegEx = regexp.MustCompile("(\\d+)(\\w)")
|
||||
|
||||
// decodes the duration of time from iCalendar format
|
||||
func (d *Duration) DecodeICalValue(value string) error {
|
||||
var seconds int64
|
||||
var isPast = strings.HasPrefix(value, "-P")
|
||||
var matches = durationRegEx.FindAllStringSubmatch(value, -1)
|
||||
for _, match := range matches {
|
||||
var multiplier int64
|
||||
ivalue, err := strconv.ParseInt(match[1], 10, 64)
|
||||
if err != nil {
|
||||
return utils.NewError(d.DecodeICalValue, "unable to decode duration value "+match[1], d, nil)
|
||||
}
|
||||
switch match[2] {
|
||||
case "S":
|
||||
multiplier = 1
|
||||
case "M":
|
||||
multiplier = 60
|
||||
case "H":
|
||||
multiplier = 60 * 60
|
||||
case "D":
|
||||
multiplier = 60 * 60 * 24
|
||||
case "W":
|
||||
multiplier = 60 * 60 * 24 * 7
|
||||
default:
|
||||
return utils.NewError(d.DecodeICalValue, "unable to decode duration segment "+match[2], d, nil)
|
||||
}
|
||||
seconds = seconds + multiplier*ivalue
|
||||
}
|
||||
d.d = time.Duration(seconds) * time.Second
|
||||
if isPast {
|
||||
d.d = -d.d
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Duration) String() string {
|
||||
if s, err := d.EncodeICalValue(); err != nil {
|
||||
panic(err)
|
||||
} else {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// creates a new iCalendar duration representation
|
||||
func NewDuration(d time.Duration) *Duration {
|
||||
return &Duration{d: d}
|
||||
}
|
||||
Generated
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
package values
|
||||
|
||||
// An access classification is only one component of the general security system within a calendar application.
|
||||
// It provides a method of capturing the scope of the access the calendar owner intends for information within an
|
||||
// individual calendar entry. The access classification of an individual iCalendar component is useful when measured
|
||||
// along with the other security components of a calendar system (e.g., calendar user authentication, authorization,
|
||||
// access rights, access role, etc.). Hence, the semantics of the individual access classifications cannot be completely
|
||||
// defined by this memo alone. Additionally, due to the "blind" nature of most exchange processes using this memo, these
|
||||
// access classifications cannot serve as an enforcement statement for a system receiving an iCalendar object. Rather,
|
||||
// they provide a method for capturing the intention of the calendar owner for the access to the calendar component.
|
||||
type EventAccessClassification string
|
||||
|
||||
const (
|
||||
PublicEventAccessClassification EventAccessClassification = "PUBLIC"
|
||||
PrivateEventAccessClassification = "PRIVATE"
|
||||
ConfidentialEventAccessClassification = "CONFIDENTIAL"
|
||||
)
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package values
|
||||
|
||||
// In a group scheduled calendar component, the property is used by the "Organizer" to provide a confirmation of the
|
||||
// event to the "Attendees".
|
||||
// For example in an Event calendar component, the "Organizer" can indicate that a meeting is tentative, confirmed or
|
||||
// cancelled.
|
||||
type EventStatus string
|
||||
|
||||
const (
|
||||
TentativeEventStatus EventStatus = "TENTATIVE" // Indicates event is tentative.
|
||||
ConfirmedEventStatus = "CONFIRMED" // Indicates event is definite.
|
||||
CancelledEventStatus = "CANCELLED" // Indicates event is cancelled.
|
||||
)
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var _ = log.Print
|
||||
|
||||
// a representation of a geographical point for iCalendar
|
||||
type Geo struct {
|
||||
coords []float64
|
||||
}
|
||||
|
||||
// creates a new icalendar geo representation
|
||||
func NewGeo(lat, lng float64) *Geo {
|
||||
return &Geo{coords: []float64{lat, lng}}
|
||||
}
|
||||
|
||||
// returns the latitude encoded into the geo point
|
||||
func (g *Geo) Lat() float64 {
|
||||
return g.coords[0]
|
||||
}
|
||||
|
||||
// returns the longitude encoded into the geo point
|
||||
func (g *Geo) Lng() float64 {
|
||||
return g.coords[1]
|
||||
}
|
||||
|
||||
// validates the geo value against the iCalendar specification
|
||||
func (g *Geo) ValidateICalValue() error {
|
||||
|
||||
if len(g.coords) != 2 {
|
||||
return utils.NewError(g.ValidateICalValue, "geo value must have length of 2", g, nil)
|
||||
}
|
||||
|
||||
if g.Lat() < -90 || g.Lat() > 90 {
|
||||
return utils.NewError(g.ValidateICalValue, "geo latitude must be between -90 and 90 degrees", g, nil)
|
||||
}
|
||||
|
||||
if g.Lng() < -180 || g.Lng() > 180 {
|
||||
return utils.NewError(g.ValidateICalValue, "geo longitude must be between -180 and 180 degrees", g, nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
// encodes the geo value for the iCalendar specification
|
||||
func (g *Geo) EncodeICalValue() (string, error) {
|
||||
return fmt.Sprintf("%f %f", g.Lat(), g.Lng()), nil
|
||||
}
|
||||
|
||||
// decodes the geo value from the iCalendar specification
|
||||
func (g *Geo) DecodeICalValue(value string) error {
|
||||
if latlng := strings.Split(value, " "); len(latlng) < 2 {
|
||||
return utils.NewError(g.DecodeICalValue, "geo value must have both a latitude and longitude component", g, nil)
|
||||
} else if lat, err := strconv.ParseFloat(latlng[0], 64); err != nil {
|
||||
return utils.NewError(g.DecodeICalValue, "unable to decode latitude component", g, err)
|
||||
} else if lng, err := strconv.ParseFloat(latlng[1], 64); err != nil {
|
||||
return utils.NewError(g.DecodeICalValue, "unable to decode latitude component", g, err)
|
||||
} else {
|
||||
*g = Geo{coords: []float64{lat, lng}}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"github.com/dolanor/caldav-go/icalendar/properties"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"log"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
var _ = log.Print
|
||||
|
||||
// Specific venues such as conference or meeting rooms may be explicitly specified using this property. An alternate
|
||||
// representation may be specified that is a URI that points to directory information with more structured specification
|
||||
// of the location. For example, the alternate representation may specify either an LDAP URI pointing to an LDAP server
|
||||
// entry or a CID URI pointing to a MIME body part containing a vCard [RFC 2426] for the location.
|
||||
type Location struct {
|
||||
value string
|
||||
altrep *url.URL
|
||||
}
|
||||
|
||||
// creates a new icalendar location representation
|
||||
func NewLocation(value string, altrep ...*url.URL) *Location {
|
||||
loc := &Location{value: value}
|
||||
if len(altrep) > 0 {
|
||||
loc.altrep = altrep[0]
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
// returns an alternate representation for the location
|
||||
// if one exists
|
||||
func (l *Location) AltRep() *url.URL {
|
||||
return l.altrep
|
||||
}
|
||||
|
||||
// encodes the location for the iCalendar specification
|
||||
func (l *Location) EncodeICalValue() (string, error) {
|
||||
return l.value, nil
|
||||
}
|
||||
|
||||
// decodes the location from the iCalendar specification
|
||||
func (l *Location) DecodeICalValue(value string) error {
|
||||
l.value = value
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodes the location params for the iCalendar specification
|
||||
func (l *Location) EncodeICalParams() (params properties.Params, err error) {
|
||||
if l.altrep != nil {
|
||||
params = properties.Params{properties.AlternateRepresentationName: l.altrep.String()}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// decodes the location params from the iCalendar specification
|
||||
func (l *Location) DecodeICalParams(params properties.Params) error {
|
||||
if rep, found := params[properties.AlternateRepresentationName]; !found {
|
||||
return nil
|
||||
} else if altrep, err := url.Parse(rep); err != nil {
|
||||
return utils.NewError(l.DecodeICalValue, "unable to parse alternate representation", l, err)
|
||||
} else {
|
||||
l.altrep = altrep
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// validates the location against the iCalendar specification
|
||||
func (l *Location) ValidateICalValue() error {
|
||||
|
||||
if l.altrep != nil {
|
||||
if _, err := url.Parse(l.altrep.String()); err != nil {
|
||||
msg := "location alternate representation must be a valid url"
|
||||
return utils.NewError(l.ValidateICalValue, msg, l, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package values
|
||||
|
||||
type Method string
|
||||
|
||||
const (
|
||||
PublishMethod Method = "PUBLISH"
|
||||
)
|
||||
+434
@@ -0,0 +1,434 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/dolanor/caldav-go/icalendar/properties"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The recurrence rule, if specified, is used in computing the recurrence set. The recurrence set is the complete set
|
||||
// of recurrence instances for a calendar component. The recurrence set is generated by considering the initial
|
||||
// "DTSTART" property along with the "RRULE", "RDATE", "EXDATE" and "EXRULE" properties contained within the iCalendar
|
||||
// object. The "DTSTART" property defines the first instance in the recurrence set. Multiple instances of the "RRULE"
|
||||
// and "EXRULE" properties can also be specified to define more sophisticated recurrence sets. The final recurrence
|
||||
// set is generated by gathering all of the start date/times generated by any of the specified "RRULE" and "RDATE"
|
||||
// properties, and excluding any start date/times which fall within the union of start date/times generated by any
|
||||
// specified "EXRULE" and "EXDATE" properties. This implies that start date/times within exclusion related properties
|
||||
// (i.e., "EXDATE" and "EXRULE") take precedence over those specified by inclusion properties
|
||||
// (i.e., "RDATE" and "RRULE"). Where duplicate instances are generated by the "RRULE" and "RDATE" properties, only
|
||||
// one recurrence is considered. Duplicate instances are ignored.
|
||||
|
||||
// The "DTSTART" and "DTEND" property pair or "DTSTART" and "DURATION" property pair, specified within the iCalendar
|
||||
// object defines the first instance of the recurrence. When used with a recurrence rule, the "DTSTART" and "DTEND"
|
||||
// properties MUST be specified in local time and the appropriate set of "VTIMEZONE" calendar components MUST be
|
||||
// included. For detail on the usage of the "VTIMEZONE" calendar component, see the "VTIMEZONE" calendar component
|
||||
// definition.
|
||||
|
||||
// Any duration associated with the iCalendar object applies to all members of the generated recurrence set. Any
|
||||
// modified duration for specific recurrences MUST be explicitly specified using the "RDATE" property.
|
||||
type RecurrenceRule struct {
|
||||
Frequency RecurrenceFrequency
|
||||
Until *DateTime
|
||||
Count int
|
||||
Interval int
|
||||
BySecond []int
|
||||
ByMinute []int
|
||||
ByHour []int
|
||||
ByDay []RecurrenceWeekday
|
||||
ByMonthDay []int
|
||||
ByYearDay []int
|
||||
ByWeekNumber []int
|
||||
ByMonth []int
|
||||
BySetPosition []int
|
||||
WeekStart RecurrenceWeekday
|
||||
}
|
||||
|
||||
var _ = log.Print
|
||||
|
||||
// the frequency an event recurs
|
||||
type RecurrenceFrequency string
|
||||
|
||||
const (
|
||||
SecondRecurrenceFrequency RecurrenceFrequency = "SECONDLY"
|
||||
MinuteRecurrenceFrequency = "MINUTELY"
|
||||
HourRecurrenceFrequency = "HOURLY"
|
||||
DayRecurrenceFrequency = "DAILY"
|
||||
WeekRecurrenceFrequency = "WEEKLY"
|
||||
MonthRecurrenceFrequency = "MONTHLY"
|
||||
YearRecurrenceFrequency = "YEARLY"
|
||||
)
|
||||
|
||||
// the frequency an event recurs
|
||||
type RecurrenceWeekday string
|
||||
|
||||
const (
|
||||
MondayRecurrenceWeekday RecurrenceWeekday = "MO"
|
||||
TuesdayRecurrenceWeekday = "TU"
|
||||
WednesdayRecurrenceWeekday = "WE"
|
||||
ThursdayRecurrenceWeekday = "TH"
|
||||
FridayRecurrenceWeekday = "FR"
|
||||
SaturdayRecurrenceWeekday = "SA"
|
||||
SundayRecurrenceWeekday = "SU"
|
||||
)
|
||||
|
||||
// creates a new recurrence rule object for iCalendar
|
||||
func NewRecurrenceRule(frequency RecurrenceFrequency) *RecurrenceRule {
|
||||
return &RecurrenceRule{Frequency: frequency}
|
||||
}
|
||||
|
||||
var weekdayRegExp = regexp.MustCompile("MO|TU|WE|TH|FR|SA|SU")
|
||||
|
||||
// returns true if weekday is a valid constant
|
||||
func (r RecurrenceWeekday) IsValidWeekDay() bool {
|
||||
return weekdayRegExp.MatchString(strings.ToUpper(string(r)))
|
||||
}
|
||||
|
||||
var frequencyRegExp = regexp.MustCompile("SECONDLY|MINUTELY|HOURLY|DAILY|WEEKLY|MONTHLY|YEARLY")
|
||||
|
||||
// returns true if weekday is a valid constant
|
||||
func (r RecurrenceFrequency) IsValidFrequency() bool {
|
||||
return frequencyRegExp.MatchString(strings.ToUpper(string(r)))
|
||||
}
|
||||
|
||||
// returns the recurrence rule name for the iCalendar specification
|
||||
func (r *RecurrenceRule) EncodeICalName() (properties.PropertyName, error) {
|
||||
return properties.RecurrenceRulePropertyName, nil
|
||||
}
|
||||
|
||||
// encodes the recurrence rule value for the iCalendar specification
|
||||
func (r *RecurrenceRule) EncodeICalValue() (string, error) {
|
||||
|
||||
out := []string{fmt.Sprintf("FREQ=%s", strings.ToUpper(string(r.Frequency)))}
|
||||
|
||||
if r.Until != nil {
|
||||
if encoded, err := r.Until.EncodeICalValue(); err != nil {
|
||||
return "", utils.NewError(r.EncodeICalValue, "unable to encode until date", r, err)
|
||||
} else {
|
||||
out = append(out, fmt.Sprintf("UNTIL=%s", encoded))
|
||||
}
|
||||
}
|
||||
|
||||
if r.Count > 0 {
|
||||
out = append(out, fmt.Sprintf("COUNT=%d", r.Count))
|
||||
}
|
||||
|
||||
if r.Interval > 0 {
|
||||
out = append(out, fmt.Sprintf("INTERVAL=%d", r.Interval))
|
||||
}
|
||||
|
||||
if len(r.BySecond) > 0 {
|
||||
if encoded, err := intsToCSV(r.BySecond); err != nil {
|
||||
return "", utils.NewError(r.EncodeICalValue, "unable to encode by second value", r, err)
|
||||
} else {
|
||||
out = append(out, fmt.Sprintf("BYSECOND=%s", encoded))
|
||||
}
|
||||
}
|
||||
|
||||
if len(r.ByMinute) > 0 {
|
||||
if encoded, err := intsToCSV(r.ByMinute); err != nil {
|
||||
return "", utils.NewError(r.EncodeICalValue, "unable to encode by minute value", r, err)
|
||||
} else {
|
||||
out = append(out, fmt.Sprintf("BYMINUTE=%s", encoded))
|
||||
}
|
||||
}
|
||||
|
||||
if len(r.ByHour) > 0 {
|
||||
if encoded, err := intsToCSV(r.ByHour); err != nil {
|
||||
return "", utils.NewError(r.EncodeICalValue, "unable to encode by hour value", r, err)
|
||||
} else {
|
||||
out = append(out, fmt.Sprintf("BYHOUR=%s", encoded))
|
||||
}
|
||||
}
|
||||
|
||||
if len(r.ByDay) > 0 {
|
||||
if encoded, err := daysToCSV(r.ByDay); err != nil {
|
||||
return "", utils.NewError(r.EncodeICalValue, "unable to encode by day value", r, err)
|
||||
} else {
|
||||
out = append(out, fmt.Sprintf("BYDAY=%s", encoded))
|
||||
}
|
||||
}
|
||||
|
||||
if len(r.ByMonthDay) > 0 {
|
||||
if encoded, err := intsToCSV(r.ByMonthDay); err != nil {
|
||||
return "", utils.NewError(r.EncodeICalValue, "unable to encode by month day value", r, err)
|
||||
} else {
|
||||
out = append(out, fmt.Sprintf("BYMONTHDAY=%s", encoded))
|
||||
}
|
||||
}
|
||||
|
||||
if len(r.ByYearDay) > 0 {
|
||||
if encoded, err := intsToCSV(r.ByYearDay); err != nil {
|
||||
return "", utils.NewError(r.EncodeICalValue, "unable to encode by year day value", r, err)
|
||||
} else {
|
||||
out = append(out, fmt.Sprintf("BYYEARDAY=%s", encoded))
|
||||
}
|
||||
}
|
||||
|
||||
if len(r.ByWeekNumber) > 0 {
|
||||
if encoded, err := intsToCSV(r.ByWeekNumber); err != nil {
|
||||
return "", utils.NewError(r.EncodeICalValue, "unable to encode by week number value", r, err)
|
||||
} else {
|
||||
out = append(out, fmt.Sprintf("BYWEEKNO=%s", encoded))
|
||||
}
|
||||
}
|
||||
|
||||
if len(r.ByMonth) > 0 {
|
||||
if encoded, err := intsToCSV(r.ByMonth); err != nil {
|
||||
return "", utils.NewError(r.EncodeICalValue, "unable to encode by month value", r, err)
|
||||
} else {
|
||||
out = append(out, fmt.Sprintf("BYMONTH=%s", encoded))
|
||||
}
|
||||
}
|
||||
|
||||
if len(r.BySetPosition) > 0 {
|
||||
if encoded, err := intsToCSV(r.BySetPosition); err != nil {
|
||||
return "", utils.NewError(r.EncodeICalValue, "unable to encode by set position value", r, err)
|
||||
} else {
|
||||
out = append(out, fmt.Sprintf("BYSETPOS=%s", encoded))
|
||||
}
|
||||
}
|
||||
|
||||
if r.WeekStart != "" {
|
||||
out = append(out, fmt.Sprintf("WKST=%s", r.WeekStart))
|
||||
}
|
||||
|
||||
return strings.Join(out, ";"), nil
|
||||
}
|
||||
|
||||
var rruleParamRegExp = regexp.MustCompile("(\\w+)\\s*=\\s*([^;]+)")
|
||||
|
||||
// decodes the recurrence rule value from the iCalendar specification
|
||||
func (r *RecurrenceRule) DecodeICalValue(value string) error {
|
||||
|
||||
matches := rruleParamRegExp.FindAllStringSubmatch(value, -1)
|
||||
if len(matches) <= 0 {
|
||||
return utils.NewError(r.DecodeICalValue, "no recurrence rules found", r, nil)
|
||||
}
|
||||
|
||||
for _, match := range matches {
|
||||
if err := r.decodeICalValue(match[1], match[2]); err != nil {
|
||||
msg := fmt.Sprintf("unable to decode %s value", match[1])
|
||||
return utils.NewError(r.DecodeICalValue, msg, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func (r *RecurrenceRule) decodeICalValue(name string, value string) error {
|
||||
|
||||
switch name {
|
||||
case "FREQ":
|
||||
r.Frequency = RecurrenceFrequency(value)
|
||||
case "UNTIL":
|
||||
until := new(DateTime)
|
||||
if err := until.DecodeICalValue(value); err != nil {
|
||||
return utils.NewError(r.decodeICalValue, "invalid until value "+value, r, err)
|
||||
} else {
|
||||
r.Until = until
|
||||
}
|
||||
case "COUNT":
|
||||
if count, err := strconv.ParseInt(value, 10, 64); err != nil {
|
||||
return utils.NewError(r.decodeICalValue, "invalid count value "+value, r, err)
|
||||
} else {
|
||||
r.Count = int(count)
|
||||
}
|
||||
case "INTERVAL":
|
||||
if interval, err := strconv.ParseInt(value, 10, 64); err != nil {
|
||||
return utils.NewError(r.decodeICalValue, "invalid interval value "+value, r, err)
|
||||
} else {
|
||||
r.Interval = int(interval)
|
||||
}
|
||||
case "BYSECOND":
|
||||
if ints, err := csvToInts(value); err != nil {
|
||||
return utils.NewError(r.decodeICalValue, "invalid by second value "+value, r, err)
|
||||
} else {
|
||||
r.BySecond = ints
|
||||
}
|
||||
case "BYMINUTE":
|
||||
if ints, err := csvToInts(value); err != nil {
|
||||
return utils.NewError(r.decodeICalValue, "invalid by minute value "+value, r, err)
|
||||
} else {
|
||||
r.ByMinute = ints
|
||||
}
|
||||
case "BYHOUR":
|
||||
if ints, err := csvToInts(value); err != nil {
|
||||
return utils.NewError(r.decodeICalValue, "invalid by hour value "+value, r, err)
|
||||
} else {
|
||||
r.ByHour = ints
|
||||
}
|
||||
case "BYDAY":
|
||||
if days, err := csvToDays(value); err != nil {
|
||||
return utils.NewError(r.decodeICalValue, "invalid by day value "+value, r, err)
|
||||
} else {
|
||||
r.ByDay = days
|
||||
}
|
||||
case "BYMONTHDAY":
|
||||
if ints, err := csvToInts(value); err != nil {
|
||||
return utils.NewError(r.decodeICalValue, "invalid by month day value "+value, r, err)
|
||||
} else {
|
||||
r.ByMonthDay = ints
|
||||
}
|
||||
case "BYYEARDAY":
|
||||
if ints, err := csvToInts(value); err != nil {
|
||||
return utils.NewError(r.decodeICalValue, "invalid by year day value "+value, r, err)
|
||||
} else {
|
||||
r.ByYearDay = ints
|
||||
}
|
||||
case "BYWEEKNO":
|
||||
if ints, err := csvToInts(value); err != nil {
|
||||
return utils.NewError(r.decodeICalValue, "invalid by week number value "+value, r, err)
|
||||
} else {
|
||||
r.ByWeekNumber = ints
|
||||
}
|
||||
case "BYMONTH":
|
||||
if ints, err := csvToInts(value); err != nil {
|
||||
return utils.NewError(r.decodeICalValue, "unable to encode by month value "+value, r, err)
|
||||
} else {
|
||||
r.ByMonth = ints
|
||||
}
|
||||
case "BYSETPOS":
|
||||
if ints, err := csvToInts(value); err != nil {
|
||||
return utils.NewError(r.decodeICalValue, "unable to encode by set position value "+value, r, err)
|
||||
} else {
|
||||
r.BySetPosition = ints
|
||||
}
|
||||
case "WKST":
|
||||
r.WeekStart = RecurrenceWeekday(value)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
// validates the recurrence rule value against the iCalendar specification
|
||||
func (r *RecurrenceRule) ValidateICalValue() error {
|
||||
if !r.Frequency.IsValidFrequency() {
|
||||
return utils.NewError(r.ValidateICalValue, "a frequency is required in all recurrence rules", r, nil)
|
||||
} else if r.Until != nil && r.Count > 0 {
|
||||
return utils.NewError(r.ValidateICalValue, "until and count values are mutually exclusive", r, nil)
|
||||
} else if found, fine := intsInRange(r.BySecond, 59); !fine {
|
||||
msg := fmt.Sprintf("by second value of %d is out of bounds", found)
|
||||
return utils.NewError(r.ValidateICalValue, msg, r, nil)
|
||||
} else if found, fine := intsInRange(r.ByMinute, 59); !fine {
|
||||
msg := fmt.Sprintf("by minute value of %d is out of bounds", found)
|
||||
return utils.NewError(r.ValidateICalValue, msg, r, nil)
|
||||
} else if found, fine := intsInRange(r.ByHour, 23); !fine {
|
||||
msg := fmt.Sprintf("by hour value of %d is out of bounds", found)
|
||||
return utils.NewError(r.ValidateICalValue, msg, r, nil)
|
||||
} else if err := daysInRange(r.ByDay); err != nil {
|
||||
return utils.NewError(r.ValidateICalValue, "by day value not in range", r, err)
|
||||
} else if found, fine := intsInRange(r.ByMonthDay, 31); !fine {
|
||||
msg := fmt.Sprintf("by month day value of %d is out of bounds", found)
|
||||
return utils.NewError(r.ValidateICalValue, msg, r, nil)
|
||||
} else if found, fine := intsInRange(r.ByYearDay, 366); !fine {
|
||||
msg := fmt.Sprintf("by year day value of %d is out of bounds", found)
|
||||
return utils.NewError(r.ValidateICalValue, msg, r, nil)
|
||||
} else if found, fine := intsInRange(r.ByMonth, 12); !fine {
|
||||
msg := fmt.Sprintf("by month value of %d is out of bounds", found)
|
||||
return utils.NewError(r.ValidateICalValue, msg, r, nil)
|
||||
} else if found, fine := intsInRange(r.BySetPosition, 366); !fine {
|
||||
msg := fmt.Sprintf("by month value of %d is out of bounds", found)
|
||||
return utils.NewError(r.ValidateICalValue, msg, r, nil)
|
||||
} else if err := dayInRange(r.WeekStart); r.WeekStart != "" && err != nil {
|
||||
return utils.NewError(r.ValidateICalValue, "week start value not in range", r, err)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func intsToCSV(ints []int) (string, error) {
|
||||
csv := new(CSV)
|
||||
for _, i := range ints {
|
||||
*csv = append(*csv, fmt.Sprintf("%d", i))
|
||||
}
|
||||
return csv.EncodeICalValue()
|
||||
}
|
||||
|
||||
func csvToInts(value string) (ints []int, err error) {
|
||||
csv := new(CSV)
|
||||
if ierr := csv.DecodeICalValue(value); err != nil {
|
||||
err = utils.NewError(csvToInts, "unable to decode CSV value", value, ierr)
|
||||
return
|
||||
}
|
||||
for _, v := range *csv {
|
||||
if i, ierr := strconv.ParseInt(v, 10, 64); err != nil {
|
||||
err = utils.NewError(csvToInts, "unable to parse int value "+v, value, ierr)
|
||||
return
|
||||
} else {
|
||||
ints = append(ints, int(i))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func intsInRange(ints []int, max int) (int, bool) {
|
||||
for _, i := range ints {
|
||||
if i < -max || i > max {
|
||||
return i, false
|
||||
}
|
||||
}
|
||||
return 0, true
|
||||
}
|
||||
|
||||
func daysInRange(days []RecurrenceWeekday) error {
|
||||
for _, day := range days {
|
||||
if err := dayInRange(day); err != nil {
|
||||
msg := fmt.Sprintf("day value %s is not in range", day)
|
||||
return utils.NewError(dayInRange, msg, days, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var dayRegExp = regexp.MustCompile("(\\d{1,2})?(\\w{2})")
|
||||
|
||||
func dayInRange(day RecurrenceWeekday) error {
|
||||
var ordinal, weekday string
|
||||
if matches := dayRegExp.FindAllStringSubmatch(string(day), -1); len(matches) <= 0 {
|
||||
msg := fmt.Sprintf("weekday value %s is not in valid format", day)
|
||||
return utils.NewError(dayInRange, msg, day, nil)
|
||||
} else if len(matches[0]) > 2 {
|
||||
ordinal = matches[0][1]
|
||||
weekday = matches[0][2]
|
||||
} else {
|
||||
weekday = matches[0][1]
|
||||
}
|
||||
if !RecurrenceWeekday(weekday).IsValidWeekDay() {
|
||||
msg := fmt.Sprintf("weekday value %s is not valid", weekday)
|
||||
return utils.NewError(dayInRange, msg, day, nil)
|
||||
} else if i, err := strconv.ParseInt(ordinal, 10, 64); ordinal != "" && err != nil {
|
||||
msg := fmt.Sprintf("weekday ordinal value %d is not valid", i)
|
||||
return utils.NewError(dayInRange, msg, day, err)
|
||||
} else if i < -53 || i > 53 {
|
||||
msg := fmt.Sprintf("weekday ordinal value %d is not in range", i)
|
||||
return utils.NewError(dayInRange, msg, day, nil)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func daysToCSV(days []RecurrenceWeekday) (string, error) {
|
||||
csv := new(CSV)
|
||||
for _, day := range days {
|
||||
*csv = append(*csv, strings.ToUpper(string(day)))
|
||||
}
|
||||
return csv.EncodeICalValue()
|
||||
}
|
||||
|
||||
func csvToDays(value string) (days []RecurrenceWeekday, err error) {
|
||||
csv := new(CSV)
|
||||
if ierr := csv.DecodeICalValue(value); err != nil {
|
||||
err = utils.NewError(csvToInts, "unable to decode CSV value", value, ierr)
|
||||
return
|
||||
}
|
||||
for _, v := range *csv {
|
||||
days = append(days, RecurrenceWeekday(v))
|
||||
}
|
||||
return
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package values
|
||||
|
||||
// Time Transparency is the characteristic of an event that determines whether it appears to consume time on a calendar.
|
||||
// Events that consume actual time for the individual or resource associated with the calendar SHOULD be recorded as
|
||||
// OPAQUE, allowing them to be detected by free-busy time searches. Other events, which do not take up the individual's
|
||||
// (or resource's) time SHOULD be recorded as TRANSPARENT, making them invisible to free-busy time searches.
|
||||
type TimeTransparency string
|
||||
|
||||
const (
|
||||
OpaqueTimeTransparency TimeTransparency = "OPAQUE" // Blocks or opaque on busy time searches. DEFAULT
|
||||
TransparentTimeTransparency = "TRANSPARENT" // Transparent on busy time searches.
|
||||
)
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"github.com/dolanor/caldav-go/icalendar/properties"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// a representation of duration for iCalendar
|
||||
type Url struct {
|
||||
u url.URL
|
||||
}
|
||||
|
||||
// encodes the URL into iCalendar format
|
||||
func (u *Url) EncodeICalValue() (string, error) {
|
||||
return u.u.String(), nil
|
||||
}
|
||||
|
||||
// encodes the url params for the iCalendar specification
|
||||
func (u *Url) EncodeICalParams() (params properties.Params, err error) {
|
||||
params = properties.Params{
|
||||
properties.ValuePropertyName: "URI",
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// decodes the URL from iCalendar format
|
||||
func (u *Url) DecodeICalValue(value string) error {
|
||||
if parsed, err := url.Parse(value); err != nil {
|
||||
return utils.NewError(u.ValidateICalValue, "unable to parse url", u, err)
|
||||
} else {
|
||||
u.u = *parsed
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// validates the URL for iCalendar format
|
||||
func (u *Url) ValidateICalValue() error {
|
||||
if _, err := url.Parse(u.u.String()); err != nil {
|
||||
return utils.NewError(u.ValidateICalValue, "invalid URL object", u, err)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// creates a new iCalendar duration representation
|
||||
func NewUrl(u url.URL) *Url {
|
||||
return &Url{u: u}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
type Error struct {
|
||||
method interface{}
|
||||
message string
|
||||
context interface{}
|
||||
cause error
|
||||
}
|
||||
|
||||
func NewError(method interface{}, message string, context interface{}, cause error) *Error {
|
||||
e := new(Error)
|
||||
e.method = method
|
||||
e.message = message
|
||||
e.context = context
|
||||
e.cause = cause
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
pc := reflect.ValueOf(e.method).Pointer()
|
||||
fn := runtime.FuncForPC(pc).Name()
|
||||
msg := fmt.Sprintf("error: %s\nfunc: %s", e.message, fn)
|
||||
if e.context != nil {
|
||||
tname := reflect.ValueOf(e.context).Type()
|
||||
msg = fmt.Sprintf("%s\ncontext: %s", msg, tname.String())
|
||||
}
|
||||
if e.cause != nil {
|
||||
msg = fmt.Sprintf("%s\ncause: %s", msg, e.cause.Error())
|
||||
}
|
||||
return msg
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/dolanor/caldav-go/http"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"github.com/dolanor/caldav-go/webdav/entities"
|
||||
nhttp "net/http"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusMulti = 207
|
||||
)
|
||||
|
||||
// a client for making WebDAV requests
|
||||
type Client http.Client
|
||||
|
||||
// downcasts the client to the local HTTP interface
|
||||
func (c *Client) Http() *http.Client {
|
||||
return (*http.Client)(c)
|
||||
}
|
||||
|
||||
// returns the embedded WebDav server reference
|
||||
func (c *Client) Server() *Server {
|
||||
return (*Server)(c.Http().Server())
|
||||
}
|
||||
|
||||
// executes a WebDAV request
|
||||
func (c *Client) Do(req *Request) (*Response, error) {
|
||||
if resp, err := c.Http().Do((*http.Request)(req)); err != nil {
|
||||
return nil, utils.NewError(c.Do, "unable to execute WebDAV request", c, err)
|
||||
} else {
|
||||
return NewResponse(resp), nil
|
||||
}
|
||||
}
|
||||
|
||||
// checks if a resource exists given a particular path
|
||||
func (c *Client) Exists(path string) (bool, error) {
|
||||
if req, err := c.Server().NewRequest("HEAD", path); err != nil {
|
||||
return false, utils.NewError(c.Exists, "unable to create request", c, err)
|
||||
} else if resp, err := c.Do(req); err != nil {
|
||||
return false, utils.NewError(c.Exists, "unable to execute request", c, err)
|
||||
} else {
|
||||
return resp.StatusCode != nhttp.StatusNotFound, nil
|
||||
}
|
||||
}
|
||||
|
||||
// deletes a resource if it exists on a particular path
|
||||
func (c *Client) Delete(path string) error {
|
||||
if req, err := c.Server().NewRequest("DELETE", path); err != nil {
|
||||
return utils.NewError(c.Delete, "unable to create request", c, err)
|
||||
} else if resp, err := c.Do(req); err != nil {
|
||||
return utils.NewError(c.Delete, "unable to execute request", c, err)
|
||||
} else if resp.StatusCode != nhttp.StatusNoContent && resp.StatusCode != nhttp.StatusNotFound {
|
||||
err := new(entities.Error)
|
||||
resp.Decode(err)
|
||||
msg := fmt.Sprintf("unexpected server response %s", resp.Status)
|
||||
return utils.NewError(c.Delete, msg, c, err)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// fetches a list of WebDAV features supported by the server
|
||||
// returns an error if the server does not support DAV
|
||||
func (c *Client) Features(path string) ([]string, error) {
|
||||
if req, err := c.Server().NewRequest("OPTIONS", path); err != nil {
|
||||
return []string{}, utils.NewError(c.Features, "unable to create request", c, err)
|
||||
} else if resp, err := c.Do(req); err != nil {
|
||||
return []string{}, utils.NewError(c.Features, "unable to execute request", c, err)
|
||||
} else {
|
||||
return resp.Features(), nil
|
||||
}
|
||||
}
|
||||
|
||||
// returns an error if the server does not support WebDAV
|
||||
func (c *Client) ValidateServer(path string) error {
|
||||
if features, err := c.Features(path); err != nil {
|
||||
return utils.NewError(c.ValidateServer, "feature detection failed", c, err)
|
||||
} else if len(features) <= 0 {
|
||||
return utils.NewError(c.ValidateServer, "no DAV headers found", c, err)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// executes a PROPFIND request against the WebDAV server
|
||||
// returns a multistatus XML entity
|
||||
func (c *Client) Propfind(path string, depth Depth, pf *entities.Propfind) (*entities.Multistatus, error) {
|
||||
|
||||
ms := new(entities.Multistatus)
|
||||
|
||||
if req, err := c.Server().NewRequest("PROPFIND", path, pf); err != nil {
|
||||
return nil, utils.NewError(c.Propfind, "unable to create request", c, err)
|
||||
} else if req.Http().Native().Header.Set("Depth", string(depth)); depth == "" {
|
||||
return nil, utils.NewError(c.Propfind, "search depth must be defined", c, nil)
|
||||
} else if resp, err := c.Do(req); err != nil {
|
||||
return nil, utils.NewError(c.Propfind, "unable to execute request", c, err)
|
||||
} else if resp.StatusCode != StatusMulti {
|
||||
msg := fmt.Sprintf("unexpected status: %s", resp.Status)
|
||||
return nil, utils.NewError(c.Propfind, msg, c, nil)
|
||||
} else if err := resp.Decode(ms); err != nil {
|
||||
return nil, utils.NewError(c.Propfind, "unable to decode response", c, err)
|
||||
}
|
||||
|
||||
return ms, nil
|
||||
|
||||
}
|
||||
|
||||
// creates a new client for communicating with an WebDAV server
|
||||
func NewClient(server *Server, native *nhttp.Client) *Client {
|
||||
return (*Client)(http.NewClient((*http.Server)(server), native))
|
||||
}
|
||||
|
||||
// creates a new client for communicating with a WebDAV server
|
||||
// uses the default HTTP client from net/http
|
||||
func NewDefaultClient(server *Server) *Client {
|
||||
return NewClient(server, nhttp.DefaultClient)
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package webdav
|
||||
|
||||
type Depth string
|
||||
|
||||
const (
|
||||
Depth0 Depth = "0"
|
||||
Depth1 = "1"
|
||||
DepthInfinity = "infinity"
|
||||
)
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package entities
|
||||
|
||||
import "encoding/xml"
|
||||
|
||||
// a WebDAV error
|
||||
type Error struct {
|
||||
XMLName xml.Name `xml:"DAV: error"`
|
||||
Description string `xml:"error-description,omitempty"`
|
||||
Message string `xml:"message,omitempty"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e.Description != "" {
|
||||
return e.Description
|
||||
} else {
|
||||
return e.Message
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package entities
|
||||
|
||||
import "encoding/xml"
|
||||
|
||||
// metadata about a property
|
||||
type PropStat struct {
|
||||
XMLName xml.Name `xml:"propstat"`
|
||||
Status string `xml:"status"`
|
||||
Prop *Prop `xml:",omitempty"`
|
||||
}
|
||||
|
||||
// a multistatus response entity
|
||||
type Response struct {
|
||||
XMLName xml.Name `xml:"response"`
|
||||
Href string `xml:"href"`
|
||||
PropStats []*PropStat `xml:"propstat,omitempty"`
|
||||
}
|
||||
|
||||
// a request to find properties on an an entity or collection
|
||||
type Multistatus struct {
|
||||
XMLName xml.Name `xml:"DAV: multistatus"`
|
||||
Responses []*Response `xml:"response,omitempty"`
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
)
|
||||
|
||||
// a property of a resource
|
||||
type Prop struct {
|
||||
XMLName xml.Name `xml:"DAV: prop"`
|
||||
GetContentType string `xml:"getcontenttype,omitempty"`
|
||||
DisplayName string `xml:"displayname,omitempty"`
|
||||
ResourceType *ResourceType `xml:",omitempty"`
|
||||
CTag string `xml:"http://calendarserver.org/ns/ getctag,omitempty"`
|
||||
ETag string `xml:"http://calendarserver.org/ns/ getetag,omitempty"`
|
||||
}
|
||||
|
||||
// the type of a resource
|
||||
type ResourceType struct {
|
||||
XMLName xml.Name `xml:"resourcetype"`
|
||||
Collection *ResourceTypeCollection `xml:",omitempty"`
|
||||
Calendar *ResourceTypeCalendar `xml:",omitempty"`
|
||||
}
|
||||
|
||||
// A calendar resource type
|
||||
type ResourceTypeCalendar struct {
|
||||
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav calendar"`
|
||||
}
|
||||
|
||||
// A collection resource type
|
||||
type ResourceTypeCollection struct {
|
||||
XMLName xml.Name `xml:"collection"`
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package entities
|
||||
|
||||
import "encoding/xml"
|
||||
|
||||
// a request to find properties on an an entity or collection
|
||||
type Propfind struct {
|
||||
XMLName xml.Name `xml:"DAV: propfind"`
|
||||
AllProp *AllProp `xml:",omitempty"`
|
||||
Props []*Prop `xml:"prop,omitempty"`
|
||||
}
|
||||
|
||||
// a propfind property representing all properties
|
||||
type AllProp struct {
|
||||
XMLName xml.Name `xml:"allprop"`
|
||||
}
|
||||
|
||||
// a convenience method for searching all properties
|
||||
func NewAllPropsFind() *Propfind {
|
||||
return &Propfind{AllProp: new(AllProp)}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"github.com/dolanor/caldav-go/http"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var _ = log.Print
|
||||
|
||||
// an WebDAV request object
|
||||
type Request http.Request
|
||||
|
||||
// downcasts the request to the local HTTP interface
|
||||
func (r *Request) Http() *http.Request {
|
||||
return (*http.Request)(r)
|
||||
}
|
||||
|
||||
// creates a new WebDAV request object
|
||||
func NewRequest(method string, urlstr string, xmldata ...interface{}) (*Request, error) {
|
||||
if buffer, length, err := xmlToReadCloser(xmldata); err != nil {
|
||||
return nil, utils.NewError(NewRequest, "unable to encode xml data", xmldata, err)
|
||||
} else if r, err := http.NewRequest(method, urlstr, buffer); err != nil {
|
||||
return nil, utils.NewError(NewRequest, "unable to create request", urlstr, err)
|
||||
} else {
|
||||
if buffer != nil {
|
||||
// set the content type to XML if we have a body
|
||||
r.Native().Header.Set("Content-Type", "text/xml; charset=UTF-8")
|
||||
r.ContentLength = int64(length)
|
||||
}
|
||||
return (*Request)(r), nil
|
||||
}
|
||||
}
|
||||
|
||||
func xmlToReadCloser(xmldata ...interface{}) (io.ReadCloser, int, error) {
|
||||
var buffer []string
|
||||
for _, xmldatum := range xmldata {
|
||||
if encoded, err := xml.Marshal(xmldatum); err != nil {
|
||||
return nil, 0, utils.NewError(xmlToReadCloser, "unable to encode as xml", xmldatum, err)
|
||||
} else {
|
||||
buffer = append(buffer, string(encoded))
|
||||
}
|
||||
}
|
||||
if len(buffer) > 0 {
|
||||
var encoded = strings.Join(buffer, "\n")
|
||||
// log.Printf("[WebDAV Request]\n%+v\n", encoded)
|
||||
return ioutil.NopCloser(bytes.NewBuffer([]byte(encoded))), len(encoded), nil
|
||||
} else {
|
||||
return nil, 0, nil
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"github.com/dolanor/caldav-go/http"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var _ = log.Print
|
||||
var _ = ioutil.ReadAll
|
||||
|
||||
// a WebDAV response object
|
||||
type Response http.Response
|
||||
|
||||
// downcasts the response to the local HTTP interface
|
||||
func (r *Response) Http() *http.Response {
|
||||
return (*http.Response)(r)
|
||||
}
|
||||
|
||||
// returns a list of WebDAV features found in the response
|
||||
func (r *Response) Features() (features []string) {
|
||||
if dav := r.Header.Get("DAV"); dav != "" {
|
||||
features = strings.Split(dav, ", ")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// decodes a WebDAV XML response into the provided interface
|
||||
func (r *Response) Decode(into interface{}) error {
|
||||
// data, _ := ioutil.ReadAll(r.Body)
|
||||
// log.Printf("[WebDAV Response]\n%+v\n", string(data))
|
||||
// if err := xml.Unmarshal(data, into); err != nil {
|
||||
// return utils.NewError(r.Decode, "unable to decode response body", r, err)
|
||||
// } else {
|
||||
// return nil
|
||||
// }
|
||||
if body := r.Body; body == nil {
|
||||
return nil
|
||||
} else if decoder := xml.NewDecoder(body); decoder == nil {
|
||||
return nil
|
||||
} else if err := decoder.Decode(into); err != nil {
|
||||
return utils.NewError(r.Decode, "unable to decode response body", r, err)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// creates a new WebDAV response object
|
||||
func NewResponse(response *http.Response) *Response {
|
||||
return (*Response)(response)
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"github.com/dolanor/caldav-go/http"
|
||||
"github.com/dolanor/caldav-go/utils"
|
||||
)
|
||||
|
||||
// a server that accepts WebDAV requests
|
||||
type Server http.Server
|
||||
|
||||
// creates a reference to an WebDAV server
|
||||
func NewServer(baseUrlStr string) (*Server, error) {
|
||||
if s, err := http.NewServer(baseUrlStr); err != nil {
|
||||
return nil, utils.NewError(NewServer, "unable to create WebDAV server", baseUrlStr, err)
|
||||
} else {
|
||||
return (*Server)(s), nil
|
||||
}
|
||||
}
|
||||
|
||||
// downcasts the server to the local HTTP interface
|
||||
func (s *Server) Http() *http.Server {
|
||||
return (*http.Server)(s)
|
||||
}
|
||||
|
||||
// creates a new WebDAV request object
|
||||
func (s *Server) NewRequest(method string, path string, xmldata ...interface{}) (*Request, error) {
|
||||
return NewRequest(method, s.Http().AbsUrlStr(path), xmldata...)
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 JSON Schema Go Project Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package jsonschema
|
||||
|
||||
import "maps"
|
||||
|
||||
// An annotations tracks certain properties computed by keywords that are used by validation.
|
||||
// ("Annotation" is the spec's term.)
|
||||
// In particular, the unevaluatedItems and unevaluatedProperties keywords need to know which
|
||||
// items and properties were evaluated (validated successfully).
|
||||
type annotations struct {
|
||||
allItems bool // all items were evaluated
|
||||
endIndex int // 1+largest index evaluated by prefixItems
|
||||
evaluatedIndexes map[int]bool // set of indexes evaluated by contains
|
||||
allProperties bool // all properties were evaluated
|
||||
evaluatedProperties map[string]bool // set of properties evaluated by various keywords
|
||||
}
|
||||
|
||||
// noteIndex marks i as evaluated.
|
||||
func (a *annotations) noteIndex(i int) {
|
||||
if a.evaluatedIndexes == nil {
|
||||
a.evaluatedIndexes = map[int]bool{}
|
||||
}
|
||||
a.evaluatedIndexes[i] = true
|
||||
}
|
||||
|
||||
// noteEndIndex marks items with index less than end as evaluated.
|
||||
func (a *annotations) noteEndIndex(end int) {
|
||||
if end > a.endIndex {
|
||||
a.endIndex = end
|
||||
}
|
||||
}
|
||||
|
||||
// noteProperty marks prop as evaluated.
|
||||
func (a *annotations) noteProperty(prop string) {
|
||||
if a.evaluatedProperties == nil {
|
||||
a.evaluatedProperties = map[string]bool{}
|
||||
}
|
||||
a.evaluatedProperties[prop] = true
|
||||
}
|
||||
|
||||
// noteProperties marks all the properties in props as evaluated.
|
||||
func (a *annotations) noteProperties(props map[string]bool) {
|
||||
a.evaluatedProperties = merge(a.evaluatedProperties, props)
|
||||
}
|
||||
|
||||
// merge adds b's annotations to a.
|
||||
// a must not be nil.
|
||||
func (a *annotations) merge(b *annotations) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
if b.allItems {
|
||||
a.allItems = true
|
||||
}
|
||||
if b.endIndex > a.endIndex {
|
||||
a.endIndex = b.endIndex
|
||||
}
|
||||
a.evaluatedIndexes = merge(a.evaluatedIndexes, b.evaluatedIndexes)
|
||||
if b.allProperties {
|
||||
a.allProperties = true
|
||||
}
|
||||
a.evaluatedProperties = merge(a.evaluatedProperties, b.evaluatedProperties)
|
||||
}
|
||||
|
||||
// merge adds t's keys to s and returns s.
|
||||
// If s is nil, it returns a copy of t.
|
||||
func merge[K comparable](s, t map[K]bool) map[K]bool {
|
||||
if s == nil {
|
||||
return maps.Clone(t)
|
||||
}
|
||||
maps.Copy(s, t)
|
||||
return s
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
/*
|
||||
Package jsonschema is an implementation of the [JSON Schema specification],
|
||||
a JSON-based format for describing the structure of JSON data.
|
||||
The package can be used to read schemas for code generation, and to validate
|
||||
data using the draft 2020-12 and draft-07 specifications. Validation with
|
||||
other drafts or custom meta-schemas is not supported.
|
||||
|
||||
Construct a [Schema] as you would any Go struct (for example, by writing
|
||||
a struct literal), or unmarshal a JSON schema into a [Schema] in the usual
|
||||
way (with [encoding/json], for instance). It can then be used for code
|
||||
generation or other purposes without further processing.
|
||||
You can also infer a schema from a Go struct.
|
||||
|
||||
# Resolution
|
||||
|
||||
A Schema can refer to other schemas, both inside and outside itself. These
|
||||
references must be resolved before a schema can be used for validation.
|
||||
Call [Schema.Resolve] to obtain a resolved schema (called a [Resolved]).
|
||||
If the schema has external references, pass a [ResolveOptions] with a [Loader]
|
||||
to load them. To validate default values in a schema, set
|
||||
[ResolveOptions.ValidateDefaults] to true.
|
||||
|
||||
# Validation
|
||||
|
||||
Call [Resolved.Validate] to validate a JSON value. The value must be a
|
||||
Go value that looks like the result of unmarshaling a JSON value into an
|
||||
[any] or a struct. For example, the JSON value
|
||||
|
||||
{"name": "Al", "scores": [90, 80, 100]}
|
||||
|
||||
could be represented as the Go value
|
||||
|
||||
map[string]any{
|
||||
"name": "Al",
|
||||
"scores": []any{90, 80, 100},
|
||||
}
|
||||
|
||||
or as a value of this type:
|
||||
|
||||
type Player struct {
|
||||
Name string `json:"name"`
|
||||
Scores []int `json:"scores"`
|
||||
}
|
||||
|
||||
# Inference
|
||||
|
||||
The [For] function returns a [Schema] describing the given Go type.
|
||||
Each field in the struct becomes a property of the schema.
|
||||
The values of "json" tags are respected: the field's property name is taken
|
||||
from the tag, and fields omitted from the JSON are omitted from the schema as
|
||||
well.
|
||||
For example, `jsonschema.For[Player]()` returns this schema:
|
||||
|
||||
{
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"scores": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"}
|
||||
}
|
||||
"required": ["name", "scores"],
|
||||
"additionalProperties": {"not": {}}
|
||||
}
|
||||
}
|
||||
|
||||
Use the "jsonschema" struct tag to provide a description for the property:
|
||||
|
||||
type Player struct {
|
||||
Name string `json:"name" jsonschema:"player name"`
|
||||
Scores []int `json:"scores" jsonschema:"scores of player's games"`
|
||||
}
|
||||
|
||||
# Deviations from the specification
|
||||
|
||||
Regular expressions are processed with Go's regexp package, which differs
|
||||
from ECMA 262, most significantly in not supporting back-references.
|
||||
See [this table of differences] for more.
|
||||
|
||||
The "format" keyword described in [section 7 of the validation spec] is recorded
|
||||
in the Schema, but is ignored during validation.
|
||||
It does not even produce [annotations].
|
||||
Use the "pattern" keyword instead: it will work more reliably across JSON Schema
|
||||
implementations. See [learnjsonschema.com] for more recommendations about "format".
|
||||
|
||||
The content keywords described in [section 8 of the validation spec]
|
||||
are recorded in the schema, but ignored during validation.
|
||||
|
||||
# Controlling behavior changes
|
||||
|
||||
Minor and patch releases of this package may introduce behavior changes as part
|
||||
of bug fixes or correctness improvements. To help manage the impact of such
|
||||
changes, the package allows you to access previous behaviors using the
|
||||
`JSONSCHEMAGODEBUG` environment variable. The available settings are listed
|
||||
below; additional options may be introduced in future releases.
|
||||
|
||||
- **typeschemasnull**: When set to `"1"`, the inferred schema for slices will
|
||||
*not* include the `null` type alongside the array type. It will also avoid
|
||||
adding `null` to non-native pointer types (such as `time.Time`). This restores
|
||||
the behavior from versions prior to v0.3.0. The default behavior is to include
|
||||
`null` in these cases.
|
||||
|
||||
[JSON Schema specification]: https://json-schema.org
|
||||
[section 7 of the validation spec]: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7
|
||||
[section 8 of the validation spec]: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.8
|
||||
[learnjsonschema.com]: https://www.learnjsonschema.com/2020-12/format-annotation/format/
|
||||
[this table of differences]: https://github.com/dlclark/regexp2?tab=readme-ov-file#compare-regexp-and-regexp2
|
||||
[annotations]: https://json-schema.org/draft/2020-12/json-schema-core#name-annotations
|
||||
*/
|
||||
package jsonschema
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file contains functions that infer a schema from a Go type.
|
||||
|
||||
package jsonschema
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"math"
|
||||
"math/big"
|
||||
"os"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
const debugEnv = "JSONSCHEMAGODEBUG"
|
||||
|
||||
// ForOptions are options for the [For] and [ForType] functions.
|
||||
type ForOptions struct {
|
||||
// If IgnoreInvalidTypes is true, fields that can't be represented as a JSON
|
||||
// Schema are ignored instead of causing an error.
|
||||
// This allows callers to adjust the resulting schema using custom knowledge.
|
||||
// For example, an interface type where all the possible implementations are
|
||||
// known can be described with "oneof".
|
||||
IgnoreInvalidTypes bool
|
||||
|
||||
// TypeSchemas maps types to their schemas.
|
||||
// If [For] encounters a type that is a key in this map, the
|
||||
// corresponding value is used as the resulting schema (after cloning to
|
||||
// ensure uniqueness).
|
||||
// Types in this map override the default translations, as described
|
||||
// in [For]'s documentation.
|
||||
// PropertyOrder defined in these schemas will not be used in [For] or [ForType].
|
||||
TypeSchemas map[reflect.Type]*Schema
|
||||
}
|
||||
|
||||
// For constructs a JSON schema object for the given type argument.
|
||||
// If non-nil, the provided options configure certain aspects of this contruction,
|
||||
// described below.
|
||||
|
||||
// It translates Go types into compatible JSON schema types, as follows.
|
||||
// These defaults can be overridden by [ForOptions.TypeSchemas].
|
||||
//
|
||||
// - Strings have schema type "string".
|
||||
// - Bools have schema type "boolean".
|
||||
// - Signed and unsigned integer types have schema type "integer".
|
||||
// - Floating point types have schema type "number".
|
||||
// - Slices and arrays have schema type "array", and a corresponding schema
|
||||
// for items.
|
||||
// - Maps with string key have schema type "object", and corresponding
|
||||
// schema for additionalProperties.
|
||||
// - Structs have schema type "object", and disallow additionalProperties.
|
||||
// Their properties are derived from exported struct fields, using the
|
||||
// struct field JSON name. Fields that are marked "omitempty" or "omitzero" are
|
||||
// considered optional; all other fields become required properties.
|
||||
// For structs, the PropertyOrder will be set to the field order.
|
||||
// - Some types in the standard library that implement json.Marshaler
|
||||
// translate to schemas that match the values to which they marshal.
|
||||
// For example, [time.Time] translates to the schema for strings.
|
||||
//
|
||||
// For will return an error if there is a cycle in the types.
|
||||
//
|
||||
// By default, For returns an error if t contains (possibly recursively) any of the
|
||||
// following Go types, as they are incompatible with the JSON schema spec.
|
||||
// If [ForOptions.IgnoreInvalidTypes] is true, then these types are ignored instead.
|
||||
// - maps with key other than 'string'
|
||||
// - function types
|
||||
// - channel types
|
||||
// - complex numbers
|
||||
// - unsafe pointers
|
||||
//
|
||||
// This function recognizes struct field tags named "jsonschema".
|
||||
// A jsonschema tag on a field is used as the description for the corresponding property.
|
||||
// For future compatibility, descriptions must not start with "WORD=", where WORD is a
|
||||
// sequence of non-whitespace characters.
|
||||
func For[T any](opts *ForOptions) (*Schema, error) {
|
||||
if opts == nil {
|
||||
opts = &ForOptions{}
|
||||
}
|
||||
schemas := maps.Clone(initialSchemaMap)
|
||||
// Add types from the options. They override the default ones.
|
||||
maps.Copy(schemas, opts.TypeSchemas)
|
||||
s, err := forType(reflect.TypeFor[T](), map[reflect.Type]bool{}, opts.IgnoreInvalidTypes, schemas)
|
||||
if err != nil {
|
||||
var z T
|
||||
return nil, fmt.Errorf("For[%T](): %w", z, err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ForType is like [For], but takes a [reflect.Type]
|
||||
func ForType(t reflect.Type, opts *ForOptions) (*Schema, error) {
|
||||
if opts == nil {
|
||||
opts = &ForOptions{}
|
||||
}
|
||||
schemas := maps.Clone(initialSchemaMap)
|
||||
// Add types from the options. They override the default ones.
|
||||
maps.Copy(schemas, opts.TypeSchemas)
|
||||
s, err := forType(t, map[reflect.Type]bool{}, opts.IgnoreInvalidTypes, schemas)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ForType(%s): %w", t, err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Helper to create a *float64 pointer from a value
|
||||
func f64Ptr(f float64) *float64 {
|
||||
return &f
|
||||
}
|
||||
|
||||
func forType(t reflect.Type, seen map[reflect.Type]bool, ignore bool, schemas map[reflect.Type]*Schema) (*Schema, error) {
|
||||
// Follow pointers: the schema for *T is almost the same as for T, except that
|
||||
// an explicit JSON "null" is allowed for the pointer.
|
||||
allowNull := false
|
||||
for t.Kind() == reflect.Pointer {
|
||||
allowNull = true
|
||||
t = t.Elem()
|
||||
}
|
||||
|
||||
// Check for cycles
|
||||
// User defined types have a name, so we can skip those that are natively defined
|
||||
if t.Name() != "" {
|
||||
if seen[t] {
|
||||
return nil, fmt.Errorf("cycle detected for type %v", t)
|
||||
}
|
||||
seen[t] = true
|
||||
defer delete(seen, t)
|
||||
}
|
||||
|
||||
if s := schemas[t]; s != nil {
|
||||
cloned := s.CloneSchemas()
|
||||
if os.Getenv(debugEnv) != "typeschemasnull=1" && allowNull {
|
||||
if cloned.Type != "" {
|
||||
cloned.Types = []string{"null", cloned.Type}
|
||||
cloned.Type = ""
|
||||
} else if !slices.Contains(cloned.Types, "null") {
|
||||
cloned.Types = append([]string{"null"}, cloned.Types...)
|
||||
}
|
||||
}
|
||||
return cloned, nil
|
||||
}
|
||||
|
||||
var (
|
||||
s = new(Schema)
|
||||
err error
|
||||
)
|
||||
|
||||
switch t.Kind() {
|
||||
case reflect.Bool:
|
||||
s.Type = "boolean"
|
||||
|
||||
case reflect.Int, reflect.Int64:
|
||||
s.Type = "integer"
|
||||
|
||||
case reflect.Uint, reflect.Uint64, reflect.Uintptr:
|
||||
s.Type = "integer"
|
||||
s.Minimum = f64Ptr(0)
|
||||
|
||||
case reflect.Int8:
|
||||
s.Type = "integer"
|
||||
s.Minimum = f64Ptr(math.MinInt8)
|
||||
s.Maximum = f64Ptr(math.MaxInt8)
|
||||
|
||||
case reflect.Uint8:
|
||||
s.Type = "integer"
|
||||
s.Minimum = f64Ptr(0)
|
||||
s.Maximum = f64Ptr(math.MaxUint8)
|
||||
|
||||
case reflect.Int16:
|
||||
s.Type = "integer"
|
||||
s.Minimum = f64Ptr(math.MinInt16)
|
||||
s.Maximum = f64Ptr(math.MaxInt16)
|
||||
|
||||
case reflect.Uint16:
|
||||
s.Type = "integer"
|
||||
s.Minimum = f64Ptr(0)
|
||||
s.Maximum = f64Ptr(math.MaxUint16)
|
||||
|
||||
case reflect.Int32:
|
||||
s.Type = "integer"
|
||||
s.Minimum = f64Ptr(math.MinInt32)
|
||||
s.Maximum = f64Ptr(math.MaxInt32)
|
||||
|
||||
case reflect.Uint32:
|
||||
s.Type = "integer"
|
||||
s.Minimum = f64Ptr(0)
|
||||
s.Maximum = f64Ptr(math.MaxUint32)
|
||||
|
||||
case reflect.Float32, reflect.Float64:
|
||||
s.Type = "number"
|
||||
|
||||
case reflect.Interface:
|
||||
// Unrestricted
|
||||
|
||||
case reflect.Map:
|
||||
if t.Key().Kind() != reflect.String && !t.Key().Implements(reflect.TypeFor[encoding.TextMarshaler]()) {
|
||||
if ignore {
|
||||
return nil, nil // ignore
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported map key type %v", t.Key().Kind())
|
||||
}
|
||||
s.Type = "object"
|
||||
s.AdditionalProperties, err = forType(t.Elem(), seen, ignore, schemas)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("computing map value schema: %v", err)
|
||||
}
|
||||
if ignore && s.AdditionalProperties == nil {
|
||||
// Ignore if the element type is invalid.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
case reflect.Slice, reflect.Array:
|
||||
if os.Getenv(debugEnv) != "typeschemasnull=1" && t.Kind() == reflect.Slice {
|
||||
s.Types = []string{"null", "array"}
|
||||
} else {
|
||||
s.Type = "array"
|
||||
}
|
||||
itemsSchema, err := forType(t.Elem(), seen, ignore, schemas)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("computing element schema: %v", err)
|
||||
}
|
||||
if itemsSchema == nil {
|
||||
return nil, nil
|
||||
}
|
||||
s.Items = itemsSchema
|
||||
if ignore && s.Items == nil {
|
||||
// Ignore if the element type is invalid.
|
||||
return nil, nil
|
||||
}
|
||||
if t.Kind() == reflect.Array {
|
||||
s.MinItems = Ptr(t.Len())
|
||||
s.MaxItems = Ptr(t.Len())
|
||||
}
|
||||
|
||||
case reflect.String:
|
||||
s.Type = "string"
|
||||
|
||||
case reflect.Struct:
|
||||
s.Type = "object"
|
||||
// no additional properties are allowed
|
||||
s.AdditionalProperties = falseSchema()
|
||||
|
||||
// If skipPath is non-nil, it is path to an anonymous field whose
|
||||
// schema has been replaced by a known schema.
|
||||
var skipPath []int
|
||||
for _, field := range reflect.VisibleFields(t) {
|
||||
if s.Properties == nil {
|
||||
s.Properties = make(map[string]*Schema)
|
||||
}
|
||||
if field.Anonymous {
|
||||
override := schemas[field.Type]
|
||||
if override != nil {
|
||||
// Type must be object, and only properties can be set.
|
||||
if override.Type != "object" {
|
||||
return nil, fmt.Errorf(`custom schema for embedded struct must have type "object", got %q`,
|
||||
override.Type)
|
||||
}
|
||||
// Check that all keywords relevant for objects are absent, except properties.
|
||||
ov := reflect.ValueOf(override).Elem()
|
||||
for _, sfi := range schemaFieldInfos {
|
||||
if sfi.sf.Name == "Type" || sfi.sf.Name == "Properties" {
|
||||
continue
|
||||
}
|
||||
fv := ov.FieldByIndex(sfi.sf.Index)
|
||||
if !fv.IsZero() {
|
||||
return nil, fmt.Errorf(`overrides for embedded fields can have only "Type" and "Properties"; this has %q`, sfi.sf.Name)
|
||||
}
|
||||
}
|
||||
|
||||
skipPath = field.Index
|
||||
keys := make([]string, 0, len(override.Properties))
|
||||
for k := range override.Properties {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
for _, name := range keys {
|
||||
if _, ok := s.Properties[name]; !ok {
|
||||
s.Properties[name] = override.Properties[name].CloneSchemas()
|
||||
s.PropertyOrder = append(s.PropertyOrder, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check to see if this field has been promoted from a replaced anonymous
|
||||
// type.
|
||||
if skipPath != nil {
|
||||
skip := false
|
||||
if len(field.Index) >= len(skipPath) {
|
||||
skip = true
|
||||
for i, index := range skipPath {
|
||||
if field.Index[i] != index {
|
||||
// If we're no longer in a subfield.
|
||||
skip = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if skip {
|
||||
continue
|
||||
} else {
|
||||
// Anonymous fields are followed immediately by their promoted fields.
|
||||
// Once we encounter a field that *isn't* promoted, we can stop
|
||||
// checking.
|
||||
skipPath = nil
|
||||
}
|
||||
}
|
||||
|
||||
info := fieldJSONInfo(field)
|
||||
if info.omit {
|
||||
continue
|
||||
}
|
||||
fs, err := forType(field.Type, seen, ignore, schemas)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ignore && fs == nil {
|
||||
// Skip fields of invalid type.
|
||||
continue
|
||||
}
|
||||
if tag, ok := field.Tag.Lookup("jsonschema"); ok {
|
||||
if tag == "" {
|
||||
return nil, fmt.Errorf("empty jsonschema tag on struct field %s.%s", t, field.Name)
|
||||
}
|
||||
if disallowedPrefixRegexp.MatchString(tag) {
|
||||
return nil, fmt.Errorf("tag must not begin with 'WORD=': %q", tag)
|
||||
}
|
||||
fs.Description = tag
|
||||
}
|
||||
s.Properties[info.name] = fs
|
||||
|
||||
s.PropertyOrder = append(s.PropertyOrder, info.name)
|
||||
|
||||
if !info.settings["omitempty"] && !info.settings["omitzero"] {
|
||||
s.Required = append(s.Required, info.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove PropertyOrder duplicates, keeping the last occurrence
|
||||
if len(s.PropertyOrder) > 1 {
|
||||
seen := make(map[string]bool)
|
||||
// Create a slice to hold the cleaned order (capacity = current length)
|
||||
cleaned := make([]string, 0, len(s.PropertyOrder))
|
||||
|
||||
// Iterate backwards
|
||||
for i := len(s.PropertyOrder) - 1; i >= 0; i-- {
|
||||
name := s.PropertyOrder[i]
|
||||
if !seen[name] {
|
||||
cleaned = append(cleaned, name)
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Since we collected them backwards, we need to reverse the result
|
||||
// to restore the correct order.
|
||||
slices.Reverse(cleaned)
|
||||
s.PropertyOrder = cleaned
|
||||
}
|
||||
|
||||
default:
|
||||
if ignore {
|
||||
// Ignore.
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("type %v is unsupported by jsonschema", t)
|
||||
}
|
||||
if allowNull && s.Type != "" {
|
||||
s.Types = []string{"null", s.Type}
|
||||
s.Type = ""
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// initialSchemaMap holds types from the standard library that have MarshalJSON methods.
|
||||
var initialSchemaMap = make(map[reflect.Type]*Schema)
|
||||
|
||||
func init() {
|
||||
ss := &Schema{Type: "string"}
|
||||
initialSchemaMap[reflect.TypeFor[time.Time]()] = ss
|
||||
initialSchemaMap[reflect.TypeFor[slog.Level]()] = ss
|
||||
if os.Getenv(debugEnv) == "typeschemasnull=1" {
|
||||
initialSchemaMap[reflect.TypeFor[big.Int]()] = &Schema{Types: []string{"null", "string"}}
|
||||
} else {
|
||||
initialSchemaMap[reflect.TypeFor[big.Int]()] = ss
|
||||
}
|
||||
initialSchemaMap[reflect.TypeFor[big.Rat]()] = ss
|
||||
initialSchemaMap[reflect.TypeFor[big.Float]()] = ss
|
||||
}
|
||||
|
||||
// Disallow jsonschema tag values beginning "WORD=", for future expansion.
|
||||
var disallowedPrefixRegexp = regexp.MustCompile("^[^ \t\n]*=")
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file implements JSON Pointers.
|
||||
// A JSON Pointer is a path that refers to one JSON value within another.
|
||||
// If the path is empty, it refers to the root value.
|
||||
// Otherwise, it is a sequence of slash-prefixed strings, like "/points/1/x",
|
||||
// selecting successive properties (for JSON objects) or items (for JSON arrays).
|
||||
// For example, when applied to this JSON value:
|
||||
// {
|
||||
// "points": [
|
||||
// {"x": 1, "y": 2},
|
||||
// {"x": 3, "y": 4}
|
||||
// ]
|
||||
// }
|
||||
//
|
||||
// the JSON Pointer "/points/1/x" refers to the number 3.
|
||||
// See the spec at https://datatracker.ietf.org/doc/html/rfc6901.
|
||||
|
||||
package jsonschema
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
jsonPointerEscaper = strings.NewReplacer("~", "~0", "/", "~1")
|
||||
jsonPointerUnescaper = strings.NewReplacer("~0", "~", "~1", "/")
|
||||
)
|
||||
|
||||
func escapeJSONPointerSegment(s string) string {
|
||||
return jsonPointerEscaper.Replace(s)
|
||||
}
|
||||
|
||||
func unescapeJSONPointerSegment(s string) string {
|
||||
return jsonPointerUnescaper.Replace(s)
|
||||
}
|
||||
|
||||
// parseJSONPointer splits a JSON Pointer into a sequence of segments. It doesn't
|
||||
// convert strings to numbers, because that depends on the traversal: a segment
|
||||
// is treated as a number when applied to an array, but a string when applied to
|
||||
// an object. See section 4 of the spec.
|
||||
func parseJSONPointer(ptr string) (segments []string, err error) {
|
||||
if ptr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if ptr[0] != '/' {
|
||||
return nil, fmt.Errorf("JSON Pointer %q does not begin with '/'", ptr)
|
||||
}
|
||||
// Unlike file paths, consecutive slashes are not coalesced.
|
||||
// Split is nicer than Cut here, because it gets a final "/" right.
|
||||
segments = strings.Split(ptr[1:], "/")
|
||||
if strings.Contains(ptr, "~") {
|
||||
// Undo the simple escaping rules that allow one to include a slash in a segment.
|
||||
for i := range segments {
|
||||
segments[i] = unescapeJSONPointerSegment(segments[i])
|
||||
}
|
||||
}
|
||||
return segments, nil
|
||||
}
|
||||
|
||||
// dereferenceJSONPointer returns the Schema that sptr points to within s,
|
||||
// or an error if none.
|
||||
// This implementation suffices for JSON Schema: pointers are applied only to Schemas,
|
||||
// and refer only to Schemas.
|
||||
func dereferenceJSONPointer(s *Schema, sptr string) (_ *Schema, err error) {
|
||||
defer wrapf(&err, "JSON Pointer %q", sptr)
|
||||
|
||||
segments, err := parseJSONPointer(sptr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := reflect.ValueOf(s)
|
||||
for _, seg := range segments {
|
||||
switch v.Kind() {
|
||||
case reflect.Pointer:
|
||||
v = v.Elem()
|
||||
if !v.IsValid() {
|
||||
return nil, errors.New("navigated to nil reference")
|
||||
}
|
||||
fallthrough // if valid, can only be a pointer to a Schema
|
||||
|
||||
case reflect.Struct:
|
||||
// The segment must refer to a field in a Schema.
|
||||
if v.Type() != reflect.TypeFor[Schema]() {
|
||||
return nil, fmt.Errorf("navigated to non-Schema %s", v.Type())
|
||||
}
|
||||
v = lookupSchemaField(v, seg)
|
||||
if !v.IsValid() {
|
||||
return nil, fmt.Errorf("no schema field %q", seg)
|
||||
}
|
||||
case reflect.Slice, reflect.Array:
|
||||
// The segment must be an integer without leading zeroes that refers to an item in the
|
||||
// slice or array.
|
||||
if seg == "-" {
|
||||
return nil, errors.New("the JSON Pointer array segment '-' is not supported")
|
||||
}
|
||||
if len(seg) > 1 && seg[0] == '0' {
|
||||
return nil, fmt.Errorf("segment %q has leading zeroes", seg)
|
||||
}
|
||||
n, err := strconv.Atoi(seg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid int: %q", seg)
|
||||
}
|
||||
if n < 0 || n >= v.Len() {
|
||||
return nil, fmt.Errorf("index %d is out of bounds for array of length %d", n, v.Len())
|
||||
}
|
||||
v = v.Index(n)
|
||||
// Cannot be invalid.
|
||||
case reflect.Map:
|
||||
// The segment must be a key in the map.
|
||||
v = v.MapIndex(reflect.ValueOf(seg))
|
||||
if !v.IsValid() {
|
||||
return nil, fmt.Errorf("no key %q in map", seg)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("value %s (%s) is not a schema, slice or map", v, v.Type())
|
||||
}
|
||||
}
|
||||
if s, ok := v.Interface().(*Schema); ok {
|
||||
return s, nil
|
||||
}
|
||||
return nil, fmt.Errorf("does not refer to a schema, but to a %s", v.Type())
|
||||
}
|
||||
|
||||
// lookupSchemaField returns the value of the field with the given name in v,
|
||||
// or the zero value if there is no such field or it is not of type Schema or *Schema.
|
||||
func lookupSchemaField(v reflect.Value, name string) reflect.Value {
|
||||
if name == "type" {
|
||||
// The "type" keyword may refer to Type or Types.
|
||||
// At most one will be non-zero.
|
||||
if t := v.FieldByName("Type"); !t.IsZero() {
|
||||
return t
|
||||
}
|
||||
return v.FieldByName("Types")
|
||||
}
|
||||
if name == "items" {
|
||||
// The "items" keyword refers to the "union type" that is either a schema or a schema array.
|
||||
// Implemented using the Items representing the schema and ItemsArray for the schema array.
|
||||
if items := v.FieldByName("Items"); items.IsValid() && !items.IsNil() {
|
||||
return items
|
||||
}
|
||||
return v.FieldByName("ItemsArray")
|
||||
}
|
||||
if name == "dependencies" {
|
||||
// The "dependencies" keyword refers to both DependencyStrings and DependencySchemas maps.
|
||||
// The value on schemaFieldMap is not garanteed to be DependencySchemas which we want
|
||||
// for pointer dereference. So we use FieldByName to get the DependencySchemas map.
|
||||
return v.FieldByName("DependencySchemas")
|
||||
}
|
||||
if sf, ok := schemaFieldMap[name]; ok {
|
||||
return v.FieldByIndex(sf.Index)
|
||||
}
|
||||
return reflect.Value{}
|
||||
}
|
||||
+589
@@ -0,0 +1,589 @@
|
||||
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file deals with preparing a schema for validation, including various checks,
|
||||
// optimizations, and the resolution of cross-schema references.
|
||||
|
||||
package jsonschema
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A Resolved consists of a [Schema] along with associated information needed to
|
||||
// validate documents against it.
|
||||
// A Resolved has been validated against its meta-schema, and all its references
|
||||
// (the $ref and $dynamicRef keywords) have been resolved to their referenced Schemas.
|
||||
// Call [Schema.Resolve] to obtain a Resolved from a Schema.
|
||||
type Resolved struct {
|
||||
root *Schema
|
||||
draft draft
|
||||
// map from $ids to their schemas
|
||||
resolvedURIs map[string]*Schema
|
||||
// map from schemas to additional info computed during resolution
|
||||
resolvedInfos map[*Schema]*resolvedInfo
|
||||
}
|
||||
|
||||
type draft int
|
||||
|
||||
const (
|
||||
draft7 = iota
|
||||
draft2020
|
||||
)
|
||||
|
||||
func newResolved(s *Schema) *Resolved {
|
||||
return &Resolved{
|
||||
root: s,
|
||||
draft: detectDraft(s),
|
||||
resolvedURIs: map[string]*Schema{},
|
||||
resolvedInfos: map[*Schema]*resolvedInfo{},
|
||||
}
|
||||
}
|
||||
|
||||
// detectDraft inspects the raw JSON to determine the schema version.
|
||||
func detectDraft(s *Schema) draft {
|
||||
// Check explicit $schema declaration
|
||||
switch s.Schema {
|
||||
case draft7SchemaVersion, draft7SecSchemaVersion:
|
||||
return draft7
|
||||
case draft202012SchemaVersion:
|
||||
return draft2020
|
||||
default:
|
||||
// If nothing matches default to the latest supported version.
|
||||
return draft2020
|
||||
}
|
||||
}
|
||||
|
||||
// resolvedInfo holds information specific to a schema that is computed by [Schema.Resolve].
|
||||
type resolvedInfo struct {
|
||||
s *Schema
|
||||
// The JSON Pointer path from the root schema to here.
|
||||
// Used in errors.
|
||||
path string
|
||||
// The schema's base schema.
|
||||
// If the schema is the root or has an ID, its base is itself.
|
||||
// Otherwise, its base is the innermost enclosing schema whose base
|
||||
// is itself.
|
||||
// Intuitively, a base schema is one that can be referred to with a
|
||||
// fragmentless URI.
|
||||
base *Schema
|
||||
// The URI for the schema, if it is the root or has an ID.
|
||||
// Otherwise nil.
|
||||
// Invariants:
|
||||
// s.base.uri != nil.
|
||||
// s.base == s <=> s.uri != nil
|
||||
uri *url.URL
|
||||
// The schema to which Ref refers.
|
||||
resolvedRef *Schema
|
||||
|
||||
// If the schema has a dynamic ref, exactly one of the next two fields
|
||||
// will be non-zero after successful resolution.
|
||||
// The schema to which the dynamic ref refers when it acts lexically.
|
||||
resolvedDynamicRef *Schema
|
||||
// The anchor to look up on the stack when the dynamic ref acts dynamically.
|
||||
dynamicRefAnchor string
|
||||
|
||||
// The following fields are independent of arguments to Schema.Resolved,
|
||||
// so they could live on the Schema. We put them here for simplicity.
|
||||
|
||||
// The set of required properties.
|
||||
isRequired map[string]bool
|
||||
|
||||
// Compiled regexps.
|
||||
pattern *regexp.Regexp
|
||||
patternProperties map[*regexp.Regexp]*Schema
|
||||
|
||||
// Map from anchors to subschemas.
|
||||
anchors map[string]anchorInfo
|
||||
}
|
||||
|
||||
// Schema returns the schema that was resolved.
|
||||
// It must not be modified.
|
||||
func (r *Resolved) Schema() *Schema { return r.root }
|
||||
|
||||
// schemaString returns a short string describing the schema.
|
||||
func (r *Resolved) schemaString(s *Schema) string {
|
||||
if s.ID != "" {
|
||||
return s.ID
|
||||
}
|
||||
info := r.resolvedInfos[s]
|
||||
if info.path != "" {
|
||||
return info.path
|
||||
}
|
||||
return "<anonymous schema>"
|
||||
}
|
||||
|
||||
// A Loader reads and unmarshals the schema at uri, if any.
|
||||
type Loader func(uri *url.URL) (*Schema, error)
|
||||
|
||||
// ResolveOptions are options for [Schema.Resolve].
|
||||
type ResolveOptions struct {
|
||||
// BaseURI is the URI relative to which the root schema should be resolved.
|
||||
// If non-empty, must be an absolute URI (one that starts with a scheme).
|
||||
// It is resolved (in the URI sense; see [url.ResolveReference]) with root's
|
||||
// $id property.
|
||||
// If the resulting URI is not absolute, then the schema cannot contain
|
||||
// relative URI references.
|
||||
BaseURI string
|
||||
// Loader loads schemas that are referred to by a $ref but are not under the
|
||||
// root schema (remote references).
|
||||
// If nil, resolving a remote reference will return an error.
|
||||
Loader Loader
|
||||
// ValidateDefaults determines whether to validate values of "default" keywords
|
||||
// against their schemas.
|
||||
// The [JSON Schema specification] does not require this, but it is recommended
|
||||
// if defaults will be used.
|
||||
//
|
||||
// [JSON Schema specification]: https://json-schema.org/understanding-json-schema/reference/annotations
|
||||
ValidateDefaults bool
|
||||
}
|
||||
|
||||
// Resolve resolves all references within the schema and performs other tasks that
|
||||
// prepare the schema for validation.
|
||||
// If opts is nil, the default values are used.
|
||||
// The schema must not be changed after Resolve is called.
|
||||
// The same schema may be resolved multiple times.
|
||||
func (root *Schema) Resolve(opts *ResolveOptions) (*Resolved, error) {
|
||||
// There are up to five steps required to prepare a schema to validate.
|
||||
// 1. Load: read the schema from somewhere and unmarshal it.
|
||||
// This schema (root) may have been loaded or created in memory, but other schemas that
|
||||
// come into the picture in step 4 will be loaded by the given loader.
|
||||
// 2. Check: validate the schema against a meta-schema, and perform other well-formedness checks.
|
||||
// Precompute some values along the way.
|
||||
// 3. Resolve URIs: determine the base URI of the root and all its subschemas, and
|
||||
// resolve (in the URI sense) all identifiers and anchors with their bases. This step results
|
||||
// in a map from URIs to schemas within root.
|
||||
// 4. Resolve references: all refs in the schemas are replaced with the schema they refer to.
|
||||
// 5. (Optional.) If opts.ValidateDefaults is true, validate the defaults.
|
||||
r := &resolver{loaded: map[string]*Resolved{}}
|
||||
if opts != nil {
|
||||
r.opts = *opts
|
||||
}
|
||||
var base *url.URL
|
||||
if r.opts.BaseURI == "" {
|
||||
base = &url.URL{} // so we can call ResolveReference on it
|
||||
} else {
|
||||
var err error
|
||||
base, err = url.Parse(r.opts.BaseURI)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing base URI: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if r.opts.Loader == nil {
|
||||
r.opts.Loader = func(uri *url.URL) (*Schema, error) {
|
||||
return nil, errors.New("cannot resolve remote schemas: no loader passed to Schema.Resolve")
|
||||
}
|
||||
}
|
||||
|
||||
resolved, err := r.resolve(root, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.opts.ValidateDefaults {
|
||||
if err := resolved.validateDefaults(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// TODO: before we return, throw away anything we don't need for validation.
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// A resolver holds the state for resolution.
|
||||
type resolver struct {
|
||||
opts ResolveOptions
|
||||
// A cache of loaded and partly resolved schemas. (They may not have had their
|
||||
// refs resolved.) The cache ensures that the loader will never be called more
|
||||
// than once with the same URI, and that reference cycles are handled properly.
|
||||
loaded map[string]*Resolved
|
||||
}
|
||||
|
||||
func (r *resolver) resolve(s *Schema, baseURI *url.URL) (*Resolved, error) {
|
||||
if baseURI.Fragment != "" {
|
||||
return nil, fmt.Errorf("base URI %s must not have a fragment", baseURI)
|
||||
}
|
||||
rs := newResolved(s)
|
||||
|
||||
if err := s.check(rs.resolvedInfos); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := resolveURIs(rs, baseURI); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Remember the schema by both the URI we loaded it from and its canonical name,
|
||||
// which may differ if the schema has an $id.
|
||||
// We must set the map before calling resolveRefs, or ref cycles will cause unbounded recursion.
|
||||
r.loaded[baseURI.String()] = rs
|
||||
r.loaded[rs.resolvedInfos[s].uri.String()] = rs
|
||||
|
||||
if err := r.resolveRefs(rs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
func (root *Schema) check(infos map[*Schema]*resolvedInfo) error {
|
||||
// Check for structural validity. Do this first and fail fast:
|
||||
// bad structure will cause other code to panic.
|
||||
if err := root.checkStructure(infos); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var errs []error
|
||||
report := func(err error) { errs = append(errs, err) }
|
||||
|
||||
for ss := range root.all() {
|
||||
ss.checkLocal(report, infos)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// checkStructure verifies that root and its subschemas form a tree.
|
||||
// It also assigns each schema a unique path, to improve error messages.
|
||||
func (root *Schema) checkStructure(infos map[*Schema]*resolvedInfo) error {
|
||||
assert(len(infos) == 0, "non-empty infos")
|
||||
|
||||
var check func(reflect.Value, []byte) error
|
||||
check = func(v reflect.Value, path []byte) error {
|
||||
// For the purpose of error messages, the root schema has path "root"
|
||||
// and other schemas' paths are their JSON Pointer from the root.
|
||||
p := "root"
|
||||
if len(path) > 0 {
|
||||
p = string(path)
|
||||
}
|
||||
s := v.Interface().(*Schema)
|
||||
if s == nil {
|
||||
return fmt.Errorf("jsonschema: schema at %s is nil", p)
|
||||
}
|
||||
if info, ok := infos[s]; ok {
|
||||
// We've seen s before.
|
||||
// The schema graph at root is not a tree, but it needs to
|
||||
// be because a schema's base must be unique.
|
||||
// A cycle would also put Schema.all into an infinite recursion.
|
||||
return fmt.Errorf("jsonschema: schemas at %s do not form a tree; %s appears more than once (also at %s)",
|
||||
root, info.path, p)
|
||||
}
|
||||
infos[s] = &resolvedInfo{s: s, path: p}
|
||||
|
||||
for _, info := range schemaFieldInfos {
|
||||
fv := v.Elem().FieldByIndex(info.sf.Index)
|
||||
switch info.sf.Type {
|
||||
case schemaType:
|
||||
// A field that contains an individual schema.
|
||||
// A nil is valid: it just means the field isn't present.
|
||||
if !fv.IsNil() {
|
||||
if err := check(fv, fmt.Appendf(path, "/%s", info.jsonName)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
case schemaSliceType:
|
||||
for i := range fv.Len() {
|
||||
if err := check(fv.Index(i), fmt.Appendf(path, "/%s/%d", info.jsonName, i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
case schemaMapType:
|
||||
iter := fv.MapRange()
|
||||
for iter.Next() {
|
||||
key := escapeJSONPointerSegment(iter.Key().String())
|
||||
if err := check(iter.Value(), fmt.Appendf(path, "/%s/%s", info.jsonName, key)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return check(reflect.ValueOf(root), make([]byte, 0, 256))
|
||||
}
|
||||
|
||||
// checkLocal checks s for validity, independently of other schemas it may refer to.
|
||||
// Since checking a regexp involves compiling it, checkLocal saves those compiled regexps
|
||||
// in the schema for later use.
|
||||
// It appends the errors it finds to errs.
|
||||
func (s *Schema) checkLocal(report func(error), infos map[*Schema]*resolvedInfo) {
|
||||
addf := func(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
report(fmt.Errorf("jsonschema.Schema: %s: %s", s, msg))
|
||||
}
|
||||
|
||||
if s == nil {
|
||||
addf("nil subschema")
|
||||
return
|
||||
}
|
||||
if err := s.basicChecks(); err != nil {
|
||||
report(err)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: validate the schema's properties,
|
||||
// ideally by jsonschema-validating it against the meta-schema.
|
||||
|
||||
// Some properties are present so that Schemas can round-trip, but we do not
|
||||
// validate them.
|
||||
// Currently, it's just the $vocabulary property.
|
||||
// As a special case, we can validate the 2020-12 meta-schema.
|
||||
if s.Vocabulary != nil && s.Schema != draft202012SchemaVersion {
|
||||
addf("cannot validate a schema with $vocabulary")
|
||||
}
|
||||
|
||||
info := infos[s]
|
||||
|
||||
// Check and compile regexps.
|
||||
if s.Pattern != "" {
|
||||
re, err := regexp.Compile(s.Pattern)
|
||||
if err != nil {
|
||||
addf("pattern: %v", err)
|
||||
} else {
|
||||
info.pattern = re
|
||||
}
|
||||
}
|
||||
if len(s.PatternProperties) > 0 {
|
||||
info.patternProperties = map[*regexp.Regexp]*Schema{}
|
||||
for reString, subschema := range s.PatternProperties {
|
||||
re, err := regexp.Compile(reString)
|
||||
if err != nil {
|
||||
addf("patternProperties[%q]: %v", reString, err)
|
||||
continue
|
||||
}
|
||||
info.patternProperties[re] = subschema
|
||||
}
|
||||
}
|
||||
|
||||
// Build a set of required properties, to avoid quadratic behavior when validating
|
||||
// a struct.
|
||||
if len(s.Required) > 0 {
|
||||
info.isRequired = map[string]bool{}
|
||||
for _, r := range s.Required {
|
||||
info.isRequired[r] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveURIs resolves the ids and anchors in all the schemas of root, relative
|
||||
// to baseURI.
|
||||
// See https://json-schema.org/draft/2020-12/json-schema-core#section-8.2, section
|
||||
// 8.2.1.
|
||||
//
|
||||
// Every schema has a base URI and a parent base URI.
|
||||
//
|
||||
// The parent base URI is the base URI of the lexically enclosing schema, or for
|
||||
// a root schema, the URI it was loaded from or the one supplied to [Schema.Resolve].
|
||||
//
|
||||
// If the schema has no $id property, the base URI of a schema is that of its parent.
|
||||
// If the schema does have an $id, it must be a URI, possibly relative. The schema's
|
||||
// base URI is the $id resolved (in the sense of [url.URL.ResolveReference]) against
|
||||
// the parent base.
|
||||
//
|
||||
// As an example, consider this schema loaded from http://a.com/root.json (quotes omitted):
|
||||
//
|
||||
// {
|
||||
// allOf: [
|
||||
// {$id: "sub1.json", minLength: 5},
|
||||
// {$id: "http://b.com", minimum: 10},
|
||||
// {not: {maximum: 20}}
|
||||
// ]
|
||||
// }
|
||||
//
|
||||
// The base URIs are as follows. Schema locations are expressed in the JSON Pointer notation.
|
||||
//
|
||||
// schema base URI
|
||||
// root http://a.com/root.json
|
||||
// allOf/0 http://a.com/sub1.json
|
||||
// allOf/1 http://b.com (absolute $id; doesn't matter that it's not under the loaded URI)
|
||||
// allOf/2 http://a.com/root.json (inherited from parent)
|
||||
// allOf/2/not http://a.com/root.json (inherited from parent)
|
||||
func resolveURIs(rs *Resolved, baseURI *url.URL) error {
|
||||
// Anchors and dynamic anchors are URI fragments that are scoped to their base.
|
||||
// We treat them as keys in a map stored within the schema.
|
||||
setAnchor := func(s *Schema, baseInfo *resolvedInfo, anchor string, dynamic bool) error {
|
||||
if anchor != "" {
|
||||
if _, ok := baseInfo.anchors[anchor]; ok {
|
||||
return fmt.Errorf("duplicate anchor %q in %s", anchor, baseInfo.uri)
|
||||
}
|
||||
if baseInfo.anchors == nil {
|
||||
baseInfo.anchors = map[string]anchorInfo{}
|
||||
}
|
||||
baseInfo.anchors[anchor] = anchorInfo{s, dynamic}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var resolve func(s, base *Schema) error
|
||||
resolve = func(s, base *Schema) error {
|
||||
info := rs.resolvedInfos[s]
|
||||
baseInfo := rs.resolvedInfos[base]
|
||||
|
||||
// ids are scoped to the root.
|
||||
if s.ID != "" {
|
||||
// draft-7 specific
|
||||
// https://json-schema.org/draft-07/draft-handrews-json-schema-01#rfc.section.8.3
|
||||
// "All other properties in a "$ref" object MUST be ignored."
|
||||
ignore := rs.draft == draft7 && s.Ref != ""
|
||||
if !ignore {
|
||||
// A non-empty ID establishes a new base.
|
||||
idURI, err := url.Parse(s.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rs.draft == draft2020 && idURI.Fragment != "" {
|
||||
return fmt.Errorf("$id %s must not have a fragment", s.ID)
|
||||
}
|
||||
if rs.draft == draft7 && idURI.Fragment != "" {
|
||||
// anchor did not exist in draft 7, id was used for base uri and document navigation
|
||||
// https://json-schema.org/draft-07/draft-handrews-json-schema-01#id-keyword
|
||||
anchorName := strings.TrimPrefix(s.ID, "#")
|
||||
setAnchor(s, baseInfo, anchorName, false)
|
||||
} else {
|
||||
// The base URI for this schema is its $id resolved against the parent base.
|
||||
info.uri = baseInfo.uri.ResolveReference(idURI)
|
||||
if !info.uri.IsAbs() {
|
||||
return fmt.Errorf("$id %s does not resolve to an absolute URI (base is %q)", s.ID, baseInfo.uri)
|
||||
}
|
||||
rs.resolvedURIs[info.uri.String()] = s
|
||||
base = s // needed for anchors
|
||||
baseInfo = rs.resolvedInfos[base]
|
||||
}
|
||||
}
|
||||
}
|
||||
info.base = base
|
||||
if rs.draft == draft2020 {
|
||||
setAnchor(s, baseInfo, s.Anchor, false)
|
||||
setAnchor(s, baseInfo, s.DynamicAnchor, true)
|
||||
}
|
||||
|
||||
for c := range s.children() {
|
||||
if err := resolve(c, base); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set the root URI to the base for now. If the root has an $id, this will change.
|
||||
rs.resolvedInfos[rs.root].uri = baseURI
|
||||
// The original base, even if changed, is still a valid way to refer to the root.
|
||||
rs.resolvedURIs[baseURI.String()] = rs.root
|
||||
|
||||
return resolve(rs.root, rs.root)
|
||||
}
|
||||
|
||||
// resolveRefs replaces every ref in the schemas with the schema it refers to.
|
||||
// A reference that doesn't resolve within the schema may refer to some other schema
|
||||
// that needs to be loaded.
|
||||
func (r *resolver) resolveRefs(rs *Resolved) error {
|
||||
for s := range rs.root.all() {
|
||||
info := rs.resolvedInfos[s]
|
||||
if s.Ref != "" {
|
||||
refSchema, _, err := r.resolveRef(rs, s, s.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Whether or not the anchor referred to by $ref fragment is dynamic,
|
||||
// the ref still treats it lexically.
|
||||
info.resolvedRef = refSchema
|
||||
}
|
||||
if s.DynamicRef != "" {
|
||||
refSchema, frag, err := r.resolveRef(rs, s, s.DynamicRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if frag != "" {
|
||||
// The dynamic ref's fragment points to a dynamic anchor.
|
||||
// We must resolve the fragment at validation time.
|
||||
info.dynamicRefAnchor = frag
|
||||
} else {
|
||||
// There is no dynamic anchor in the lexically referenced schema,
|
||||
// so the dynamic ref behaves like a lexical ref.
|
||||
info.resolvedDynamicRef = refSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveRef resolves the reference ref, which is either s.Ref or s.DynamicRef.
|
||||
func (r *resolver) resolveRef(rs *Resolved, s *Schema, ref string) (_ *Schema, dynamicFragment string, err error) {
|
||||
refURI, err := url.Parse(ref)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
// URI-resolve the ref against the current base URI to get a complete URI.
|
||||
base := rs.resolvedInfos[s].base
|
||||
refURI = rs.resolvedInfos[base].uri.ResolveReference(refURI)
|
||||
// The non-fragment part of a ref URI refers to the base URI of some schema.
|
||||
// This part is the same for dynamic refs too: their non-fragment part resolves
|
||||
// lexically.
|
||||
u := *refURI
|
||||
u.Fragment = ""
|
||||
fraglessRefURI := &u
|
||||
// Look it up locally.
|
||||
referencedSchema := rs.resolvedURIs[fraglessRefURI.String()]
|
||||
if referencedSchema == nil {
|
||||
// The schema is remote. Maybe we've already loaded it.
|
||||
// We assume that the non-fragment part of refURI refers to a top-level schema
|
||||
// document. That is, we don't support the case exemplified by
|
||||
// http://foo.com/bar.json/baz, where the document is in bar.json and
|
||||
// the reference points to a subschema within it.
|
||||
// TODO: support that case.
|
||||
if lrs := r.loaded[fraglessRefURI.String()]; lrs != nil {
|
||||
referencedSchema = lrs.root
|
||||
} else {
|
||||
// Try to load the schema.
|
||||
ls, err := r.opts.Loader(fraglessRefURI)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("loading %s: %w", fraglessRefURI, err)
|
||||
}
|
||||
// Check if referenced schema has $schema defined. If not it should inherit the resolved
|
||||
if ls.Schema == "" {
|
||||
ls.Schema = s.Schema
|
||||
}
|
||||
lrs, err := r.resolve(ls, fraglessRefURI)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
referencedSchema = lrs.root
|
||||
assert(referencedSchema != nil, "nil referenced schema")
|
||||
// Copy the resolvedInfos from lrs into rs, without overwriting
|
||||
// (hence we can't use maps.Insert).
|
||||
for s, i := range lrs.resolvedInfos {
|
||||
if rs.resolvedInfos[s] == nil {
|
||||
rs.resolvedInfos[s] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frag := refURI.Fragment
|
||||
// Look up frag in refSchema.
|
||||
// frag is either a JSON Pointer or the name of an anchor.
|
||||
// A JSON Pointer is either the empty string or begins with a '/',
|
||||
// whereas anchors are always non-empty strings that don't contain slashes.
|
||||
if frag != "" && !strings.HasPrefix(frag, "/") {
|
||||
resInfo := rs.resolvedInfos[referencedSchema]
|
||||
info, found := resInfo.anchors[frag]
|
||||
|
||||
if !found {
|
||||
return nil, "", fmt.Errorf("no anchor %q in %s", frag, s)
|
||||
}
|
||||
if info.dynamic {
|
||||
dynamicFragment = frag
|
||||
}
|
||||
return info.schema, dynamicFragment, nil
|
||||
}
|
||||
// frag is a JSON Pointer.
|
||||
s, err = dereferenceJSONPointer(referencedSchema, frag)
|
||||
return s, "", err
|
||||
}
|
||||
+642
@@ -0,0 +1,642 @@
|
||||
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package jsonschema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"maps"
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// A Schema is a JSON schema object.
|
||||
// It supports both draft-07 and the 2020-12 draft specifications:
|
||||
// - Draft-07: https://json-schema.org/draft-07/draft-handrews-json-schema-01
|
||||
// and https://json-schema.org/draft-07/draft-handrews-json-schema-validation-01
|
||||
// - Draft 2020-12: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-01
|
||||
// and https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01
|
||||
//
|
||||
// A Schema value may have non-zero values for more than one field:
|
||||
// all relevant non-zero fields are used for validation.
|
||||
// There is one exception to provide more Go type-safety: the Type and Types fields
|
||||
// are mutually exclusive.
|
||||
//
|
||||
// Since this struct is a Go representation of a JSON value, it inherits JSON's
|
||||
// distinction between nil and empty. Nil slices and maps are considered absent,
|
||||
// but empty ones are present and affect validation. For example,
|
||||
//
|
||||
// Schema{Enum: nil}
|
||||
//
|
||||
// is equivalent to an empty schema, so it validates every instance. But
|
||||
//
|
||||
// Schema{Enum: []any{}}
|
||||
//
|
||||
// requires equality to some slice element, so it vacuously rejects every instance.
|
||||
type Schema struct {
|
||||
// core
|
||||
ID string `json:"$id,omitempty"`
|
||||
Schema string `json:"$schema,omitempty"`
|
||||
Ref string `json:"$ref,omitempty"`
|
||||
Comment string `json:"$comment,omitempty"`
|
||||
Defs map[string]*Schema `json:"$defs,omitempty"`
|
||||
Definitions map[string]*Schema `json:"definitions,omitempty"`
|
||||
|
||||
// split draft 7 Dependencies into DependencySchemas and DependencyStrings
|
||||
DependencySchemas map[string]*Schema `json:"-"`
|
||||
DependencyStrings map[string][]string `json:"-"`
|
||||
|
||||
Anchor string `json:"$anchor,omitempty"`
|
||||
DynamicAnchor string `json:"$dynamicAnchor,omitempty"`
|
||||
DynamicRef string `json:"$dynamicRef,omitempty"`
|
||||
Vocabulary map[string]bool `json:"$vocabulary,omitempty"`
|
||||
|
||||
// metadata
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Default json.RawMessage `json:"default,omitempty"`
|
||||
Deprecated bool `json:"deprecated,omitempty"`
|
||||
ReadOnly bool `json:"readOnly,omitempty"`
|
||||
WriteOnly bool `json:"writeOnly,omitempty"`
|
||||
Examples []any `json:"examples,omitempty"`
|
||||
|
||||
// validation
|
||||
// Use Type for a single type, or Types for multiple types; never both.
|
||||
Type string `json:"-"`
|
||||
Types []string `json:"-"`
|
||||
Enum []any `json:"enum,omitempty"`
|
||||
// Const is *any because a JSON null (Go nil) is a valid value.
|
||||
Const *any `json:"const,omitempty"`
|
||||
MultipleOf *float64 `json:"multipleOf,omitempty"`
|
||||
Minimum *float64 `json:"minimum,omitempty"`
|
||||
Maximum *float64 `json:"maximum,omitempty"`
|
||||
ExclusiveMinimum *float64 `json:"exclusiveMinimum,omitempty"`
|
||||
ExclusiveMaximum *float64 `json:"exclusiveMaximum,omitempty"`
|
||||
MinLength *int `json:"minLength,omitempty"`
|
||||
MaxLength *int `json:"maxLength,omitempty"`
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
|
||||
// arrays
|
||||
PrefixItems []*Schema `json:"prefixItems,omitempty"`
|
||||
Items *Schema `json:"-"`
|
||||
ItemsArray []*Schema `json:"-"`
|
||||
MinItems *int `json:"minItems,omitempty"`
|
||||
MaxItems *int `json:"maxItems,omitempty"`
|
||||
AdditionalItems *Schema `json:"additionalItems,omitempty"`
|
||||
UniqueItems bool `json:"uniqueItems,omitempty"`
|
||||
Contains *Schema `json:"contains,omitempty"`
|
||||
MinContains *int `json:"minContains,omitempty"` // *int, not int: default is 1, not 0
|
||||
MaxContains *int `json:"maxContains,omitempty"`
|
||||
UnevaluatedItems *Schema `json:"unevaluatedItems,omitempty"`
|
||||
|
||||
// objects
|
||||
MinProperties *int `json:"minProperties,omitempty"`
|
||||
MaxProperties *int `json:"maxProperties,omitempty"`
|
||||
Required []string `json:"required,omitempty"`
|
||||
DependentRequired map[string][]string `json:"dependentRequired,omitempty"`
|
||||
Properties map[string]*Schema `json:"properties,omitempty"`
|
||||
PatternProperties map[string]*Schema `json:"patternProperties,omitempty"`
|
||||
AdditionalProperties *Schema `json:"additionalProperties,omitempty"`
|
||||
PropertyNames *Schema `json:"propertyNames,omitempty"`
|
||||
UnevaluatedProperties *Schema `json:"unevaluatedProperties,omitempty"`
|
||||
|
||||
// logic
|
||||
AllOf []*Schema `json:"allOf,omitempty"`
|
||||
AnyOf []*Schema `json:"anyOf,omitempty"`
|
||||
OneOf []*Schema `json:"oneOf,omitempty"`
|
||||
Not *Schema `json:"not,omitempty"`
|
||||
|
||||
// conditional
|
||||
If *Schema `json:"if,omitempty"`
|
||||
Then *Schema `json:"then,omitempty"`
|
||||
Else *Schema `json:"else,omitempty"`
|
||||
DependentSchemas map[string]*Schema `json:"dependentSchemas,omitempty"`
|
||||
|
||||
// other
|
||||
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.8
|
||||
ContentEncoding string `json:"contentEncoding,omitempty"`
|
||||
ContentMediaType string `json:"contentMediaType,omitempty"`
|
||||
ContentSchema *Schema `json:"contentSchema,omitempty"`
|
||||
|
||||
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7
|
||||
Format string `json:"format,omitempty"`
|
||||
|
||||
// Extra allows for additional keywords beyond those specified.
|
||||
Extra map[string]any `json:"-"`
|
||||
|
||||
// PropertyOrder records the ordering of properties for JSON rendering.
|
||||
//
|
||||
// During [For], PropertyOrder is set to the field order,
|
||||
// if the type used for inference is a struct.
|
||||
//
|
||||
// If PropertyOrder is set, it controls the relative ordering of properties in [Schema.MarshalJSON].
|
||||
// The rendered JSON first lists any properties that appear in the PropertyOrder slice in the order
|
||||
// they appear, followed by all other properties that do not appear in the PropertyOrder slice in an
|
||||
// undefined but deterministic order.
|
||||
PropertyOrder []string `json:"-"`
|
||||
}
|
||||
|
||||
// falseSchema returns a new Schema tree that fails to validate any value.
|
||||
func falseSchema() *Schema {
|
||||
return &Schema{Not: &Schema{}}
|
||||
}
|
||||
|
||||
// anchorInfo records the subschema to which an anchor refers, and whether
|
||||
// the anchor keyword is $anchor or $dynamicAnchor.
|
||||
type anchorInfo struct {
|
||||
schema *Schema
|
||||
dynamic bool
|
||||
}
|
||||
|
||||
// String returns a short description of the schema.
|
||||
func (s *Schema) String() string {
|
||||
if s.ID != "" {
|
||||
return s.ID
|
||||
}
|
||||
if a := cmp.Or(s.Anchor, s.DynamicAnchor); a != "" {
|
||||
return fmt.Sprintf("anchor %s", a)
|
||||
}
|
||||
return "<anonymous schema>"
|
||||
}
|
||||
|
||||
// CloneSchemas returns a copy of s.
|
||||
// The copy is shallow except for sub-schemas, which are themelves copied with CloneSchemas.
|
||||
// This allows both s and s.CloneSchemas() to appear as sub-schemas of the same parent.
|
||||
func (s *Schema) CloneSchemas() *Schema {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s2 := *s
|
||||
v := reflect.ValueOf(&s2)
|
||||
for _, info := range schemaFieldInfos {
|
||||
fv := v.Elem().FieldByIndex(info.sf.Index)
|
||||
switch info.sf.Type {
|
||||
case schemaType:
|
||||
sscss := fv.Interface().(*Schema)
|
||||
fv.Set(reflect.ValueOf(sscss.CloneSchemas()))
|
||||
|
||||
case schemaSliceType:
|
||||
slice := fv.Interface().([]*Schema)
|
||||
slice = slices.Clone(slice)
|
||||
for i, ss := range slice {
|
||||
slice[i] = ss.CloneSchemas()
|
||||
}
|
||||
fv.Set(reflect.ValueOf(slice))
|
||||
|
||||
case schemaMapType:
|
||||
m := fv.Interface().(map[string]*Schema)
|
||||
m = maps.Clone(m)
|
||||
for k, ss := range m {
|
||||
m[k] = ss.CloneSchemas()
|
||||
}
|
||||
fv.Set(reflect.ValueOf(m))
|
||||
|
||||
}
|
||||
}
|
||||
return &s2
|
||||
}
|
||||
|
||||
func (s *Schema) basicChecks() error {
|
||||
if s.Type != "" && s.Types != nil {
|
||||
return errors.New("both Type and Types are set; at most one should be")
|
||||
}
|
||||
if s.Defs != nil && s.Definitions != nil {
|
||||
return errors.New("both Defs and Definitions are set; at most one should be")
|
||||
}
|
||||
if s.Items != nil && s.ItemsArray != nil {
|
||||
return errors.New("both Items and ItemsArray are set; at most one should be")
|
||||
}
|
||||
propertyOrderSeen := make(map[string]bool)
|
||||
for _, val := range s.PropertyOrder {
|
||||
if _, ok := propertyOrderSeen[val]; ok {
|
||||
// Duplicate found
|
||||
return fmt.Errorf("property order slice cannot contain duplicate entries, found duplicate %q", val)
|
||||
}
|
||||
propertyOrderSeen[val] = true
|
||||
}
|
||||
|
||||
for key := range s.DependencySchemas {
|
||||
// Check if the key exists in the dependency strings map
|
||||
if _, exists := s.DependencyStrings[key]; exists {
|
||||
return fmt.Errorf("dependency key %q cannot be defined as both a schema and a string array", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type schemaWithoutMethods Schema // doesn't implement json.{Unm,M}arshaler
|
||||
|
||||
func (s Schema) MarshalJSON() ([]byte, error) {
|
||||
// NOTE: Use a value receiver here to avoid the encoding/json bugs
|
||||
// described in golang/go#22967, golang/go#33993, and golang/go#55890.
|
||||
// With a pointer receiver, MarshalJSON is only called for Schema in
|
||||
// some cases (for example when the field value is addressable, or not
|
||||
// stored as a map value), which leads to inconsistent JSON encoding.
|
||||
// A value receiver makes Schema itself implement json.Marshaler and
|
||||
// ensures that encoding/json always calls this method.
|
||||
if err := s.basicChecks(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Marshal either Type or Types as "type".
|
||||
var typ any
|
||||
switch {
|
||||
case s.Type != "":
|
||||
typ = s.Type
|
||||
case s.Types != nil:
|
||||
typ = s.Types
|
||||
}
|
||||
|
||||
var items any
|
||||
switch {
|
||||
case s.Items != nil:
|
||||
items = s.Items
|
||||
case s.ItemsArray != nil:
|
||||
items = s.ItemsArray
|
||||
}
|
||||
|
||||
var dep map[string]any
|
||||
size := len(s.DependencySchemas) + len(s.DependencyStrings)
|
||||
if size > 0 {
|
||||
dep = make(map[string]any, size)
|
||||
for k, v := range s.DependencySchemas {
|
||||
dep[k] = v
|
||||
}
|
||||
for k, v := range s.DependencyStrings {
|
||||
dep[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
ms := struct {
|
||||
Type any `json:"type,omitempty"`
|
||||
Properties json.Marshaler `json:"properties,omitempty"`
|
||||
Dependencies map[string]any `json:"dependencies,omitempty"`
|
||||
Items any `json:"items,omitempty"`
|
||||
*schemaWithoutMethods
|
||||
}{
|
||||
Type: typ,
|
||||
Dependencies: dep,
|
||||
Items: items,
|
||||
schemaWithoutMethods: (*schemaWithoutMethods)(&s),
|
||||
}
|
||||
// Marshal properties, even if the empty map (but not nil).
|
||||
if s.Properties != nil {
|
||||
ms.Properties = orderedProperties{
|
||||
props: s.Properties,
|
||||
order: s.PropertyOrder,
|
||||
}
|
||||
}
|
||||
|
||||
bs, err := marshalStructWithMap(&ms, "Extra")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Marshal {} as true and {"not": {}} as false.
|
||||
// It is wasteful to do this here instead of earlier, but much easier.
|
||||
switch {
|
||||
case bytes.Equal(bs, []byte(`{}`)):
|
||||
bs = []byte("true")
|
||||
case bytes.Equal(bs, []byte(`{"not":true}`)):
|
||||
bs = []byte("false")
|
||||
}
|
||||
return bs, nil
|
||||
}
|
||||
|
||||
// orderedProperties is a helper to marshal the properties map in a specific order.
|
||||
type orderedProperties struct {
|
||||
props map[string]*Schema
|
||||
order []string
|
||||
}
|
||||
|
||||
func (op orderedProperties) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteByte('{')
|
||||
|
||||
first := true
|
||||
processed := make(map[string]bool, len(op.props))
|
||||
|
||||
// Helper closure to write "key": value
|
||||
writeEntry := func(key string, val *Schema) error {
|
||||
if !first {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
first = false
|
||||
|
||||
// Marshal the Key
|
||||
keyBytes, err := json.Marshal(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buf.Write(keyBytes)
|
||||
|
||||
buf.WriteByte(':')
|
||||
|
||||
// Marshal the Value
|
||||
valBytes, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buf.Write(valBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write keys explicitly listed in PropertyOrder
|
||||
for _, name := range op.order {
|
||||
if prop, ok := op.props[name]; ok {
|
||||
if err := writeEntry(name, prop); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
processed[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Write any remaining keys
|
||||
var remaining []string
|
||||
for name := range op.props {
|
||||
if !processed[name] {
|
||||
remaining = append(remaining, name)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the slice alphabetically
|
||||
slices.Sort(remaining)
|
||||
|
||||
for _, name := range remaining {
|
||||
if err := writeEntry(name, op.props[name]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
buf.WriteByte('}')
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (s *Schema) UnmarshalJSON(data []byte) error {
|
||||
// A JSON boolean is a valid schema.
|
||||
var b bool
|
||||
if err := json.Unmarshal(data, &b); err == nil {
|
||||
if b {
|
||||
// true is the empty schema, which validates everything.
|
||||
*s = Schema{}
|
||||
} else {
|
||||
// false is the schema that validates nothing.
|
||||
*s = *falseSchema()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
ms := struct {
|
||||
Type json.RawMessage `json:"type,omitempty"`
|
||||
Dependencies map[string]json.RawMessage `json:"dependencies,omitempty"`
|
||||
Items json.RawMessage `json:"items,omitempty"`
|
||||
Const json.RawMessage `json:"const,omitempty"`
|
||||
MinLength *integer `json:"minLength,omitempty"`
|
||||
MaxLength *integer `json:"maxLength,omitempty"`
|
||||
MinItems *integer `json:"minItems,omitempty"`
|
||||
MaxItems *integer `json:"maxItems,omitempty"`
|
||||
MinProperties *integer `json:"minProperties,omitempty"`
|
||||
MaxProperties *integer `json:"maxProperties,omitempty"`
|
||||
MinContains *integer `json:"minContains,omitempty"`
|
||||
MaxContains *integer `json:"maxContains,omitempty"`
|
||||
|
||||
*schemaWithoutMethods
|
||||
}{
|
||||
schemaWithoutMethods: (*schemaWithoutMethods)(s),
|
||||
}
|
||||
if err := unmarshalStructWithMap(data, &ms, "Extra"); err != nil {
|
||||
return err
|
||||
}
|
||||
// Unmarshal "type" as either Type or Types.
|
||||
var err error
|
||||
if len(ms.Type) > 0 {
|
||||
switch ms.Type[0] {
|
||||
case '"':
|
||||
err = json.Unmarshal(ms.Type, &s.Type)
|
||||
case '[':
|
||||
err = json.Unmarshal(ms.Type, &s.Types)
|
||||
default:
|
||||
err = fmt.Errorf(`invalid value for "type": %q`, ms.Type)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Unmarshal "items" as either Items or ItemsArray.
|
||||
if len(ms.Items) > 0 {
|
||||
switch ms.Items[0] {
|
||||
case '[':
|
||||
var schemas []*Schema
|
||||
err = json.Unmarshal(ms.Items, &schemas)
|
||||
s.ItemsArray = schemas
|
||||
default:
|
||||
var schema Schema
|
||||
err = json.Unmarshal(ms.Items, &schema)
|
||||
s.Items = &schema
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Unmarshal "Dependencies" values as either string arrays or schemas
|
||||
// and assign them to specific map DependencySchemas or DependencyStrings.
|
||||
for k, v := range ms.Dependencies {
|
||||
if len(v) > 0 {
|
||||
switch v[0] {
|
||||
case '[':
|
||||
var dstrings []string
|
||||
err = json.Unmarshal(v, &dstrings)
|
||||
if s.DependencyStrings == nil {
|
||||
s.DependencyStrings = make(map[string][]string)
|
||||
}
|
||||
s.DependencyStrings[k] = dstrings
|
||||
default:
|
||||
var dschema Schema
|
||||
err = json.Unmarshal(v, &dschema)
|
||||
if s.DependencySchemas == nil {
|
||||
s.DependencySchemas = make(map[string]*Schema)
|
||||
}
|
||||
s.DependencySchemas[k] = &dschema
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
unmarshalAnyPtr := func(p **any, raw json.RawMessage) error {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
if bytes.Equal(raw, []byte("null")) {
|
||||
*p = new(any)
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, p)
|
||||
}
|
||||
|
||||
// Setting Const to a pointer to null will marshal properly, but won't
|
||||
// unmarshal: the *any is set to nil, not a pointer to nil.
|
||||
if err := unmarshalAnyPtr(&s.Const, ms.Const); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
set := func(dst **int, src *integer) {
|
||||
if src != nil {
|
||||
*dst = Ptr(int(*src))
|
||||
}
|
||||
}
|
||||
|
||||
set(&s.MinLength, ms.MinLength)
|
||||
set(&s.MaxLength, ms.MaxLength)
|
||||
set(&s.MinItems, ms.MinItems)
|
||||
set(&s.MaxItems, ms.MaxItems)
|
||||
set(&s.MinProperties, ms.MinProperties)
|
||||
set(&s.MaxProperties, ms.MaxProperties)
|
||||
set(&s.MinContains, ms.MinContains)
|
||||
set(&s.MaxContains, ms.MaxContains)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type integer int32 // for the integer-valued fields of Schema
|
||||
|
||||
func (ip *integer) UnmarshalJSON(data []byte) error {
|
||||
if len(data) == 0 {
|
||||
// nothing to do
|
||||
return nil
|
||||
}
|
||||
// If there is a decimal point, src is a floating-point number.
|
||||
var i int64
|
||||
if bytes.ContainsRune(data, '.') {
|
||||
var f float64
|
||||
if err := json.Unmarshal(data, &f); err != nil {
|
||||
return errors.New("not a number")
|
||||
}
|
||||
i = int64(f)
|
||||
if float64(i) != f {
|
||||
return errors.New("not an integer value")
|
||||
}
|
||||
} else {
|
||||
if err := json.Unmarshal(data, &i); err != nil {
|
||||
return errors.New("cannot be unmarshaled into an int")
|
||||
}
|
||||
}
|
||||
// Ensure behavior is the same on both 32-bit and 64-bit systems.
|
||||
if i < math.MinInt32 || i > math.MaxInt32 {
|
||||
return errors.New("integer is out of range")
|
||||
}
|
||||
*ip = integer(i)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ptr returns a pointer to a new variable whose value is x.
|
||||
func Ptr[T any](x T) *T { return &x }
|
||||
|
||||
// every applies f preorder to every schema under s including s.
|
||||
// The second argument to f is the path to the schema appended to the argument path.
|
||||
// It stops when f returns false.
|
||||
func (s *Schema) every(f func(*Schema) bool) bool {
|
||||
return f(s) && s.everyChild(func(s *Schema) bool { return s.every(f) })
|
||||
}
|
||||
|
||||
// everyChild reports whether f is true for every immediate child schema of s.
|
||||
func (s *Schema) everyChild(f func(*Schema) bool) bool {
|
||||
v := reflect.ValueOf(s)
|
||||
for _, info := range schemaFieldInfos {
|
||||
fv := v.Elem().FieldByIndex(info.sf.Index)
|
||||
switch info.sf.Type {
|
||||
case schemaType:
|
||||
// A field that contains an individual schema. A nil is valid: it just means the field isn't present.
|
||||
c := fv.Interface().(*Schema)
|
||||
if c != nil && !f(c) {
|
||||
return false
|
||||
}
|
||||
|
||||
case schemaSliceType:
|
||||
slice := fv.Interface().([]*Schema)
|
||||
for _, c := range slice {
|
||||
if !f(c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
case schemaMapType:
|
||||
// Sort keys for determinism.
|
||||
m := fv.Interface().(map[string]*Schema)
|
||||
for _, k := range slices.Sorted(maps.Keys(m)) {
|
||||
if !f(m[k]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// all wraps every in an iterator.
|
||||
func (s *Schema) all() iter.Seq[*Schema] {
|
||||
return func(yield func(*Schema) bool) { s.every(yield) }
|
||||
}
|
||||
|
||||
// children wraps everyChild in an iterator.
|
||||
func (s *Schema) children() iter.Seq[*Schema] {
|
||||
return func(yield func(*Schema) bool) { s.everyChild(yield) }
|
||||
}
|
||||
|
||||
var (
|
||||
schemaType = reflect.TypeFor[*Schema]()
|
||||
schemaSliceType = reflect.TypeFor[[]*Schema]()
|
||||
schemaMapType = reflect.TypeFor[map[string]*Schema]()
|
||||
)
|
||||
|
||||
type structFieldInfo struct {
|
||||
sf reflect.StructField
|
||||
jsonName string
|
||||
}
|
||||
|
||||
var (
|
||||
// the visible fields of Schema that have a JSON name, sorted by that name
|
||||
schemaFieldInfos []structFieldInfo
|
||||
// map from JSON name to field
|
||||
schemaFieldMap = map[string]reflect.StructField{}
|
||||
)
|
||||
|
||||
func init() {
|
||||
t := reflect.VisibleFields(reflect.TypeFor[Schema]())
|
||||
for _, sf := range t {
|
||||
info := fieldJSONInfo(sf)
|
||||
if !info.omit {
|
||||
schemaFieldInfos = append(schemaFieldInfos, structFieldInfo{sf, info.name})
|
||||
} else {
|
||||
// jsoninfo.name is used to build the info paths. The items and dependencies are ommited,
|
||||
// since the original fields are separated to handle the union types supported in json and
|
||||
// these fields have custom marshalling and unmarshalling logic.
|
||||
// we still need these fields in schemaFieldInfos for creating schema trees and calculating paths and refs.
|
||||
// so we manually create them and assign the jsonName to the original field json name.
|
||||
switch sf.Name {
|
||||
case "Items", "ItemsArray":
|
||||
schemaFieldInfos = append(schemaFieldInfos, structFieldInfo{sf, "items"})
|
||||
case "DependencySchemas", "DependencyStrings":
|
||||
schemaFieldInfos = append(schemaFieldInfos, structFieldInfo{sf, "dependencies"})
|
||||
}
|
||||
}
|
||||
}
|
||||
// The value of "dependencies" this sort of schemaFieldInfos.
|
||||
// This sort is unstable and is comparing the json.names of DependencyStrings and DependencySchemas which are both "dependencies".
|
||||
// Since the sort is unstable it cannot be guarantied that "dependencies" has the DependencySchemas value.
|
||||
slices.SortFunc(schemaFieldInfos, func(i1, i2 structFieldInfo) int {
|
||||
return cmp.Compare(i1.jsonName, i2.jsonName)
|
||||
})
|
||||
for _, info := range schemaFieldInfos {
|
||||
schemaFieldMap[info.jsonName] = info.sf
|
||||
}
|
||||
}
|
||||
+463
@@ -0,0 +1,463 @@
|
||||
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package jsonschema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/maphash"
|
||||
"math"
|
||||
"math/big"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Equal reports whether two Go values representing JSON values are equal according
|
||||
// to the JSON Schema spec.
|
||||
// The values must not contain cycles.
|
||||
// See https://json-schema.org/draft/2020-12/json-schema-core#section-4.2.2.
|
||||
// It behaves like reflect.DeepEqual, except that numbers are compared according
|
||||
// to mathematical equality.
|
||||
func Equal(x, y any) bool {
|
||||
return equalValue(reflect.ValueOf(x), reflect.ValueOf(y))
|
||||
}
|
||||
|
||||
func equalValue(x, y reflect.Value) bool {
|
||||
// Copied from src/reflect/deepequal.go, omitting the visited check (because JSON
|
||||
// values are trees).
|
||||
if !x.IsValid() || !y.IsValid() {
|
||||
return x.IsValid() == y.IsValid()
|
||||
}
|
||||
|
||||
// Treat numbers specially.
|
||||
rx, ok1 := jsonNumber(x)
|
||||
ry, ok2 := jsonNumber(y)
|
||||
if ok1 && ok2 {
|
||||
return rx.Cmp(ry) == 0
|
||||
}
|
||||
if x.Kind() != y.Kind() {
|
||||
return false
|
||||
}
|
||||
switch x.Kind() {
|
||||
case reflect.Array:
|
||||
if x.Len() != y.Len() {
|
||||
return false
|
||||
}
|
||||
for i := range x.Len() {
|
||||
if !equalValue(x.Index(i), y.Index(i)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case reflect.Slice:
|
||||
if x.IsNil() != y.IsNil() {
|
||||
return false
|
||||
}
|
||||
if x.Len() != y.Len() {
|
||||
return false
|
||||
}
|
||||
if x.UnsafePointer() == y.UnsafePointer() {
|
||||
return true
|
||||
}
|
||||
// Special case for []byte, which is common.
|
||||
if x.Type().Elem().Kind() == reflect.Uint8 && x.Type() == y.Type() {
|
||||
return bytes.Equal(x.Bytes(), y.Bytes())
|
||||
}
|
||||
for i := range x.Len() {
|
||||
if !equalValue(x.Index(i), y.Index(i)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case reflect.Interface:
|
||||
if x.IsNil() || y.IsNil() {
|
||||
return x.IsNil() == y.IsNil()
|
||||
}
|
||||
return equalValue(x.Elem(), y.Elem())
|
||||
case reflect.Pointer:
|
||||
if x.UnsafePointer() == y.UnsafePointer() {
|
||||
return true
|
||||
}
|
||||
return equalValue(x.Elem(), y.Elem())
|
||||
case reflect.Struct:
|
||||
t := x.Type()
|
||||
if t != y.Type() {
|
||||
return false
|
||||
}
|
||||
for i := range t.NumField() {
|
||||
sf := t.Field(i)
|
||||
if !sf.IsExported() {
|
||||
continue
|
||||
}
|
||||
if !equalValue(x.FieldByIndex(sf.Index), y.FieldByIndex(sf.Index)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case reflect.Map:
|
||||
if x.IsNil() != y.IsNil() {
|
||||
return false
|
||||
}
|
||||
if x.Len() != y.Len() {
|
||||
return false
|
||||
}
|
||||
if x.UnsafePointer() == y.UnsafePointer() {
|
||||
return true
|
||||
}
|
||||
iter := x.MapRange()
|
||||
for iter.Next() {
|
||||
vx := iter.Value()
|
||||
vy := y.MapIndex(iter.Key())
|
||||
if !vy.IsValid() || !equalValue(vx, vy) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case reflect.Func:
|
||||
if x.Type() != y.Type() {
|
||||
return false
|
||||
}
|
||||
if x.IsNil() && y.IsNil() {
|
||||
return true
|
||||
}
|
||||
panic("cannot compare functions")
|
||||
case reflect.String:
|
||||
return x.String() == y.String()
|
||||
case reflect.Bool:
|
||||
return x.Bool() == y.Bool()
|
||||
// Ints, uints and floats handled in jsonNumber, at top of function.
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported kind: %s", x.Kind()))
|
||||
}
|
||||
}
|
||||
|
||||
// hashValue adds v to the data hashed by h. v must not have cycles.
|
||||
// hashValue panics if the value contains functions or channels, or maps whose
|
||||
// key type is not string.
|
||||
// It ignores unexported fields of structs.
|
||||
// Calls to hashValue with the equal values (in the sense
|
||||
// of [Equal]) result in the same sequence of values written to the hash.
|
||||
func hashValue(h *maphash.Hash, v reflect.Value) {
|
||||
// TODO: replace writes of basic types with WriteComparable in 1.24.
|
||||
|
||||
writeUint := func(u uint64) {
|
||||
var buf [8]byte
|
||||
binary.BigEndian.PutUint64(buf[:], u)
|
||||
h.Write(buf[:])
|
||||
}
|
||||
|
||||
var write func(reflect.Value)
|
||||
write = func(v reflect.Value) {
|
||||
if r, ok := jsonNumber(v); ok {
|
||||
// We want 1.0 and 1 to hash the same.
|
||||
// big.Rats are always normalized, so they will be.
|
||||
// We could do this more efficiently by handling the int and float cases
|
||||
// separately, but that's premature.
|
||||
writeUint(uint64(r.Sign() + 1))
|
||||
h.Write(r.Num().Bytes())
|
||||
h.Write(r.Denom().Bytes())
|
||||
return
|
||||
}
|
||||
switch v.Kind() {
|
||||
case reflect.Invalid:
|
||||
h.WriteByte(0)
|
||||
case reflect.String:
|
||||
h.WriteString(v.String())
|
||||
case reflect.Bool:
|
||||
if v.Bool() {
|
||||
h.WriteByte(1)
|
||||
} else {
|
||||
h.WriteByte(0)
|
||||
}
|
||||
case reflect.Complex64, reflect.Complex128:
|
||||
c := v.Complex()
|
||||
writeUint(math.Float64bits(real(c)))
|
||||
writeUint(math.Float64bits(imag(c)))
|
||||
case reflect.Array, reflect.Slice:
|
||||
// Although we could treat []byte more efficiently,
|
||||
// JSON values are unlikely to contain them.
|
||||
writeUint(uint64(v.Len()))
|
||||
for i := range v.Len() {
|
||||
write(v.Index(i))
|
||||
}
|
||||
case reflect.Interface, reflect.Pointer:
|
||||
write(v.Elem())
|
||||
case reflect.Struct:
|
||||
t := v.Type()
|
||||
for i := range t.NumField() {
|
||||
if sf := t.Field(i); sf.IsExported() {
|
||||
write(v.FieldByIndex(sf.Index))
|
||||
}
|
||||
}
|
||||
case reflect.Map:
|
||||
if v.Type().Key().Kind() != reflect.String {
|
||||
panic("map with non-string key")
|
||||
}
|
||||
// Sort the keys so the hash is deterministic.
|
||||
keys := v.MapKeys()
|
||||
// Write the length. That distinguishes between, say, two consecutive
|
||||
// maps with disjoint keys from one map that has the items of both.
|
||||
writeUint(uint64(len(keys)))
|
||||
slices.SortFunc(keys, func(x, y reflect.Value) int { return cmp.Compare(x.String(), y.String()) })
|
||||
for _, k := range keys {
|
||||
write(k)
|
||||
write(v.MapIndex(k))
|
||||
}
|
||||
// Ints, uints and floats handled in jsonNumber, at top of function.
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported kind: %s", v.Kind()))
|
||||
}
|
||||
}
|
||||
|
||||
write(v)
|
||||
}
|
||||
|
||||
// jsonNumber converts a numeric value or a json.Number to a [big.Rat].
|
||||
// If v is not a number, it returns nil, false.
|
||||
func jsonNumber(v reflect.Value) (*big.Rat, bool) {
|
||||
r := new(big.Rat)
|
||||
switch {
|
||||
case !v.IsValid():
|
||||
return nil, false
|
||||
case v.CanInt():
|
||||
r.SetInt64(v.Int())
|
||||
case v.CanUint():
|
||||
r.SetUint64(v.Uint())
|
||||
case v.CanFloat():
|
||||
r.SetFloat64(v.Float())
|
||||
default:
|
||||
jn, ok := v.Interface().(json.Number)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if _, ok := r.SetString(jn.String()); !ok {
|
||||
// This can fail in rare cases; for example, "1e9999999".
|
||||
// That is a valid JSON number, since the spec puts no limit on the size
|
||||
// of the exponent.
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return r, true
|
||||
}
|
||||
|
||||
// jsonType returns a string describing the type of the JSON value,
|
||||
// as described in the JSON Schema specification:
|
||||
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.1.1.
|
||||
// It returns "", false if the value is not valid JSON.
|
||||
func jsonType(v reflect.Value) (string, bool) {
|
||||
if !v.IsValid() {
|
||||
// Not v.IsNil(): a nil []any is still a JSON array.
|
||||
return "null", true
|
||||
}
|
||||
if v.CanInt() || v.CanUint() {
|
||||
return "integer", true
|
||||
}
|
||||
if v.CanFloat() {
|
||||
if _, f := math.Modf(v.Float()); f == 0 {
|
||||
return "integer", true
|
||||
}
|
||||
return "number", true
|
||||
}
|
||||
switch v.Kind() {
|
||||
case reflect.Bool:
|
||||
return "boolean", true
|
||||
case reflect.String:
|
||||
return "string", true
|
||||
case reflect.Slice, reflect.Array:
|
||||
return "array", true
|
||||
case reflect.Map, reflect.Struct:
|
||||
return "object", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func assert(cond bool, msg string) {
|
||||
if !cond {
|
||||
panic("assertion failed: " + msg)
|
||||
}
|
||||
}
|
||||
|
||||
// marshalStructWithMap marshals its first argument to JSON, treating the field named
|
||||
// mapField as an embedded map. The first argument must be a pointer to
|
||||
// a struct. The underlying type of mapField must be a map[string]any, and it must have
|
||||
// a "-" json tag, meaning it will not be marshaled.
|
||||
//
|
||||
// For example, given this struct:
|
||||
//
|
||||
// type S struct {
|
||||
// A int
|
||||
// Extra map[string] any `json:"-"`
|
||||
// }
|
||||
//
|
||||
// and this value:
|
||||
//
|
||||
// s := S{A: 1, Extra: map[string]any{"B": 2}}
|
||||
//
|
||||
// the call marshalJSONWithMap(s, "Extra") would return
|
||||
//
|
||||
// {"A": 1, "B": 2}
|
||||
//
|
||||
// It is an error if the map contains the same key as another struct field's
|
||||
// JSON name.
|
||||
//
|
||||
// marshalStructWithMap calls json.Marshal on a value of type T, so T must not
|
||||
// have a MarshalJSON method that calls this function, on pain of infinite regress.
|
||||
//
|
||||
// Note that there is a similar function in mcp/util.go, but they are not the same.
|
||||
// Here the function requires `-` json tag, does not clear the mapField map,
|
||||
// and handles embedded struct due to the implementation of jsonNames in this package.
|
||||
//
|
||||
// TODO: avoid this restriction on T by forcing it to marshal in a default way.
|
||||
// See https://go.dev/play/p/EgXKJHxEx_R.
|
||||
func marshalStructWithMap[T any](s *T, mapField string) ([]byte, error) {
|
||||
// Marshal the struct and the map separately, and concatenate the bytes.
|
||||
// This strategy is dramatically less complicated than
|
||||
// constructing a synthetic struct or map with the combined keys.
|
||||
if s == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
s2 := *s
|
||||
vMapField := reflect.ValueOf(&s2).Elem().FieldByName(mapField)
|
||||
mapVal := vMapField.Interface().(map[string]any)
|
||||
|
||||
// Check for duplicates.
|
||||
names := jsonNames(reflect.TypeFor[T]())
|
||||
for key := range mapVal {
|
||||
if names[key] {
|
||||
return nil, fmt.Errorf("map key %q duplicates struct field", key)
|
||||
}
|
||||
}
|
||||
|
||||
structBytes, err := json.Marshal(s2)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshalStructWithMap(%+v): %w", s, err)
|
||||
}
|
||||
if len(mapVal) == 0 {
|
||||
return structBytes, nil
|
||||
}
|
||||
mapBytes, err := json.Marshal(mapVal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(structBytes) == 2 { // must be "{}"
|
||||
return mapBytes, nil
|
||||
}
|
||||
// "{X}" + "{Y}" => "{X,Y}"
|
||||
res := append(structBytes[:len(structBytes)-1], ',')
|
||||
res = append(res, mapBytes[1:]...)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// unmarshalStructWithMap is the inverse of marshalStructWithMap.
|
||||
// T has the same restrictions as in that function.
|
||||
//
|
||||
// Note that there is a similar function in mcp/util.go, but they are not the same.
|
||||
// Here jsonNames also returns fields from embedded structs, hence this function
|
||||
// handles embedded structs as well.
|
||||
func unmarshalStructWithMap[T any](data []byte, v *T, mapField string) error {
|
||||
// Unmarshal into the struct, ignoring unknown fields.
|
||||
if err := json.Unmarshal(data, v); err != nil {
|
||||
return err
|
||||
}
|
||||
// Unmarshal into the map.
|
||||
m := map[string]any{}
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
// Delete from the map the fields of the struct.
|
||||
for n := range jsonNames(reflect.TypeFor[T]()) {
|
||||
delete(m, n)
|
||||
}
|
||||
if len(m) != 0 {
|
||||
reflect.ValueOf(v).Elem().FieldByName(mapField).Set(reflect.ValueOf(m))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var jsonNamesMap sync.Map // from reflect.Type to map[string]bool
|
||||
|
||||
// jsonNames returns the set of JSON object keys that t will marshal into,
|
||||
// including fields from embedded structs in t.
|
||||
// t must be a struct type.
|
||||
//
|
||||
// Note that there is a similar function in mcp/util.go, but they are not the same
|
||||
// Here the function recurses over embedded structs and includes fields from them.
|
||||
func jsonNames(t reflect.Type) map[string]bool {
|
||||
// Lock not necessary: at worst we'll duplicate work.
|
||||
if val, ok := jsonNamesMap.Load(t); ok {
|
||||
return val.(map[string]bool)
|
||||
}
|
||||
m := map[string]bool{}
|
||||
for i := range t.NumField() {
|
||||
field := t.Field(i)
|
||||
// handle embedded structs
|
||||
if field.Anonymous {
|
||||
fieldType := field.Type
|
||||
if fieldType.Kind() == reflect.Ptr {
|
||||
fieldType = fieldType.Elem()
|
||||
}
|
||||
for n := range jsonNames(fieldType) {
|
||||
m[n] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
info := fieldJSONInfo(field)
|
||||
if !info.omit {
|
||||
m[info.name] = true
|
||||
}
|
||||
}
|
||||
jsonNamesMap.Store(t, m)
|
||||
return m
|
||||
}
|
||||
|
||||
type jsonInfo struct {
|
||||
omit bool // unexported or first tag element is "-"
|
||||
name string // Go field name or first tag element. Empty if omit is true.
|
||||
settings map[string]bool // "omitempty", "omitzero", etc.
|
||||
}
|
||||
|
||||
// fieldJSONInfo reports information about how encoding/json
|
||||
// handles the given struct field.
|
||||
// If the field is unexported, jsonInfo.omit is true and no other jsonInfo field
|
||||
// is populated.
|
||||
// If the field is exported and has no tag, then name is the field's name and all
|
||||
// other fields are false.
|
||||
// Otherwise, the information is obtained from the tag.
|
||||
func fieldJSONInfo(f reflect.StructField) jsonInfo {
|
||||
if !f.IsExported() {
|
||||
return jsonInfo{omit: true}
|
||||
}
|
||||
info := jsonInfo{name: f.Name}
|
||||
if tag, ok := f.Tag.Lookup("json"); ok {
|
||||
name, rest, found := strings.Cut(tag, ",")
|
||||
// "-" means omit, but "-," means the name is "-"
|
||||
if name == "-" && !found {
|
||||
return jsonInfo{omit: true}
|
||||
}
|
||||
if name != "" {
|
||||
info.name = name
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
info.settings = map[string]bool{}
|
||||
for _, s := range strings.Split(rest, ",") {
|
||||
info.settings[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// wrapf wraps *errp with the given formatted message if *errp is not nil.
|
||||
func wrapf(errp *error, format string, args ...any) {
|
||||
if *errp != nil {
|
||||
*errp = fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), *errp)
|
||||
}
|
||||
}
|
||||
+906
@@ -0,0 +1,906 @@
|
||||
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package jsonschema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/maphash"
|
||||
"iter"
|
||||
"math"
|
||||
"math/big"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// The values of the "$schema" keyword for the versions that we can validate.
|
||||
const (
|
||||
draft7SchemaVersion = "http://json-schema.org/draft-07/schema#"
|
||||
draft7SecSchemaVersion = "https://json-schema.org/draft-07/schema#"
|
||||
draft202012SchemaVersion = "https://json-schema.org/draft/2020-12/schema"
|
||||
)
|
||||
|
||||
// isValidSchemaVersion checks if the given schema version is supported
|
||||
func isValidSchemaVersion(version string) bool {
|
||||
return version == "" || version == draft7SchemaVersion || version == draft7SecSchemaVersion || version == draft202012SchemaVersion
|
||||
}
|
||||
|
||||
// Validate validates the instance, which must be a JSON value, against the schema.
|
||||
// It returns nil if validation is successful or an error if it is not.
|
||||
// If the schema type is "object", instance should be a map[string]any.
|
||||
func (rs *Resolved) Validate(instance any) error {
|
||||
if s := rs.root.Schema; !isValidSchemaVersion(s) {
|
||||
return fmt.Errorf("cannot validate version %s, supported versions: draft-07 and draft 2020-12", s)
|
||||
}
|
||||
st := &state{rs: rs}
|
||||
return st.validate(reflect.ValueOf(instance), st.rs.root, nil)
|
||||
}
|
||||
|
||||
// validateDefaults walks the schema tree. If it finds a default, it validates it
|
||||
// against the schema containing it.
|
||||
//
|
||||
// TODO(jba): account for dynamic refs. This algorithm simple-mindedly
|
||||
// treats each schema with a default as its own root.
|
||||
func (rs *Resolved) validateDefaults() error {
|
||||
if s := rs.root.Schema; !isValidSchemaVersion(s) {
|
||||
return fmt.Errorf("cannot validate version %s, supported versions: draft-07 and draft 2020-12", s)
|
||||
}
|
||||
st := &state{rs: rs}
|
||||
for s := range rs.root.all() {
|
||||
// We checked for nil schemas in [Schema.Resolve].
|
||||
assert(s != nil, "nil schema")
|
||||
if s.DynamicRef != "" {
|
||||
return fmt.Errorf("jsonschema: %s: validateDefaults does not support dynamic refs", rs.schemaString(s))
|
||||
}
|
||||
if s.Default != nil {
|
||||
var d any
|
||||
if err := json.Unmarshal(s.Default, &d); err != nil {
|
||||
return fmt.Errorf("unmarshaling default value of schema %s: %w", rs.schemaString(s), err)
|
||||
}
|
||||
if err := st.validate(reflect.ValueOf(d), s, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// state is the state of single call to ResolvedSchema.Validate.
|
||||
type state struct {
|
||||
rs *Resolved
|
||||
// stack holds the schemas from recursive calls to validate.
|
||||
// These are the "dynamic scopes" used to resolve dynamic references.
|
||||
// https://json-schema.org/draft/2020-12/json-schema-core#scopes
|
||||
stack []*Schema
|
||||
}
|
||||
|
||||
// validate validates the reflected value of the instance.
|
||||
func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *annotations) (err error) {
|
||||
defer wrapf(&err, "validating %s", st.rs.schemaString(schema))
|
||||
|
||||
// Maintain a stack for dynamic schema resolution.
|
||||
st.stack = append(st.stack, schema) // push
|
||||
defer func() {
|
||||
st.stack = st.stack[:len(st.stack)-1] // pop
|
||||
}()
|
||||
|
||||
// We checked for nil schemas in [Schema.Resolve].
|
||||
assert(schema != nil, "nil schema")
|
||||
|
||||
// Step through interfaces and pointers.
|
||||
for instance.Kind() == reflect.Pointer || instance.Kind() == reflect.Interface {
|
||||
instance = instance.Elem()
|
||||
}
|
||||
|
||||
schemaInfo := st.rs.resolvedInfos[schema]
|
||||
|
||||
var anns annotations // all the annotations for this call and child calls
|
||||
// $ref: https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.3.1
|
||||
if schema.Ref != "" {
|
||||
if err := st.validate(instance, schemaInfo.resolvedRef, &anns); err != nil {
|
||||
return err
|
||||
}
|
||||
// https://json-schema.org/draft-07/draft-handrews-json-schema-01#rfc.section.8.3
|
||||
// "All other properties in a "$ref" object MUST be ignored."
|
||||
if st.rs.draft == draft7 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// type: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.1.1
|
||||
if schema.Type != "" || schema.Types != nil {
|
||||
gotType, ok := jsonType(instance)
|
||||
if !ok {
|
||||
return fmt.Errorf("type: %v of type %[1]T is not a valid JSON value", instance)
|
||||
}
|
||||
if schema.Type != "" {
|
||||
// "number" subsumes integers
|
||||
if !(gotType == schema.Type ||
|
||||
gotType == "integer" && schema.Type == "number") {
|
||||
return fmt.Errorf("type: %v has type %q, want %q", instance, gotType, schema.Type)
|
||||
}
|
||||
} else {
|
||||
if !(slices.Contains(schema.Types, gotType) || (gotType == "integer" && slices.Contains(schema.Types, "number"))) {
|
||||
return fmt.Errorf("type: %v has type %q, want one of %q",
|
||||
instance, gotType, strings.Join(schema.Types, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
// enum: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.1.2
|
||||
if schema.Enum != nil {
|
||||
ok := false
|
||||
for _, e := range schema.Enum {
|
||||
if equalValue(reflect.ValueOf(e), instance) {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("enum: %v does not equal any of: %v", instance, schema.Enum)
|
||||
}
|
||||
}
|
||||
|
||||
// const: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.1.3
|
||||
if schema.Const != nil {
|
||||
if !equalValue(reflect.ValueOf(*schema.Const), instance) {
|
||||
return fmt.Errorf("const: %v does not equal %v", instance, *schema.Const)
|
||||
}
|
||||
}
|
||||
|
||||
// numbers: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.2
|
||||
if schema.MultipleOf != nil || schema.Minimum != nil || schema.Maximum != nil || schema.ExclusiveMinimum != nil || schema.ExclusiveMaximum != nil {
|
||||
n, ok := jsonNumber(instance)
|
||||
if ok { // these keywords don't apply to non-numbers
|
||||
if schema.MultipleOf != nil {
|
||||
// TODO: validate MultipleOf as non-zero.
|
||||
// The test suite assumes floats.
|
||||
nf, _ := n.Float64() // don't care if it's exact or not
|
||||
if _, f := math.Modf(nf / *schema.MultipleOf); f != 0 {
|
||||
return fmt.Errorf("multipleOf: %s is not a multiple of %f", n, *schema.MultipleOf)
|
||||
}
|
||||
}
|
||||
|
||||
m := new(big.Rat) // reuse for all of the following
|
||||
cmp := func(f float64) int { return n.Cmp(m.SetFloat64(f)) }
|
||||
|
||||
if schema.Minimum != nil && cmp(*schema.Minimum) < 0 {
|
||||
return fmt.Errorf("minimum: %s is less than %f", n, *schema.Minimum)
|
||||
}
|
||||
if schema.Maximum != nil && cmp(*schema.Maximum) > 0 {
|
||||
return fmt.Errorf("maximum: %s is greater than %f", n, *schema.Maximum)
|
||||
}
|
||||
if schema.ExclusiveMinimum != nil && cmp(*schema.ExclusiveMinimum) <= 0 {
|
||||
return fmt.Errorf("exclusiveMinimum: %s is less than or equal to %f", n, *schema.ExclusiveMinimum)
|
||||
}
|
||||
if schema.ExclusiveMaximum != nil && cmp(*schema.ExclusiveMaximum) >= 0 {
|
||||
return fmt.Errorf("exclusiveMaximum: %s is greater than or equal to %f", n, *schema.ExclusiveMaximum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// strings: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.3
|
||||
if instance.Kind() == reflect.String && (schema.MinLength != nil || schema.MaxLength != nil || schema.Pattern != "") {
|
||||
str := instance.String()
|
||||
n := utf8.RuneCountInString(str)
|
||||
if schema.MinLength != nil {
|
||||
if m := *schema.MinLength; n < m {
|
||||
return fmt.Errorf("minLength: %q contains %d Unicode code points, fewer than %d", str, n, m)
|
||||
}
|
||||
}
|
||||
if schema.MaxLength != nil {
|
||||
if m := *schema.MaxLength; n > m {
|
||||
return fmt.Errorf("maxLength: %q contains %d Unicode code points, more than %d", str, n, m)
|
||||
}
|
||||
}
|
||||
|
||||
if schema.Pattern != "" && !schemaInfo.pattern.MatchString(str) {
|
||||
return fmt.Errorf("pattern: %q does not match regular expression %q", str, schema.Pattern)
|
||||
}
|
||||
}
|
||||
|
||||
// $dynamicRef: https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.3.2
|
||||
if schema.DynamicRef != "" {
|
||||
// The ref behaves lexically or dynamically, but not both.
|
||||
assert((schemaInfo.resolvedDynamicRef == nil) != (schemaInfo.dynamicRefAnchor == ""),
|
||||
"DynamicRef not resolved properly")
|
||||
if schemaInfo.resolvedDynamicRef != nil {
|
||||
// Same as $ref.
|
||||
if err := st.validate(instance, schemaInfo.resolvedDynamicRef, &anns); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Dynamic behavior.
|
||||
// Look for the base of the outermost schema on the stack with this dynamic
|
||||
// anchor. (Yes, outermost: the one farthest from here. This the opposite
|
||||
// of how ordinary dynamic variables behave.)
|
||||
// Why the base of the schema being validated and not the schema itself?
|
||||
// Because the base is the scope for anchors. In fact it's possible to
|
||||
// refer to a schema that is not on the stack, but a child of some base
|
||||
// on the stack.
|
||||
// For an example, search for "detached" in testdata/draft2020-12/dynamicRef.json.
|
||||
var dynamicSchema *Schema
|
||||
for _, s := range st.stack {
|
||||
base := st.rs.resolvedInfos[s].base
|
||||
info, ok := st.rs.resolvedInfos[base].anchors[schemaInfo.dynamicRefAnchor]
|
||||
if ok && info.dynamic {
|
||||
dynamicSchema = info.schema
|
||||
break
|
||||
}
|
||||
}
|
||||
if dynamicSchema == nil {
|
||||
return fmt.Errorf("missing dynamic anchor %q", schemaInfo.dynamicRefAnchor)
|
||||
}
|
||||
if err := st.validate(instance, dynamicSchema, &anns); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// logic
|
||||
// https://json-schema.org/draft/2020-12/json-schema-core#section-10.2
|
||||
// These must happen before arrays and objects because if they evaluate an item or property,
|
||||
// then the unevaluatedItems/Properties schemas don't apply to it.
|
||||
// See https://json-schema.org/draft/2020-12/json-schema-core#section-11.2, paragraph 4.
|
||||
//
|
||||
// If any of these fail, then validation fails, even if there is an unevaluatedXXX
|
||||
// keyword in the schema. The spec is unclear about this, but that is the intention.
|
||||
|
||||
valid := func(s *Schema, anns *annotations) bool { return st.validate(instance, s, anns) == nil }
|
||||
|
||||
if schema.AllOf != nil {
|
||||
for _, ss := range schema.AllOf {
|
||||
if err := st.validate(instance, ss, &anns); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if schema.AnyOf != nil {
|
||||
// We must visit them all, to collect annotations.
|
||||
var errs []error
|
||||
for _, ss := range schema.AnyOf {
|
||||
if err := st.validate(instance, ss, &anns); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if len(errs) == len(schema.AnyOf) {
|
||||
return fmt.Errorf("anyOf: did not validate against any of %v:\n%v",
|
||||
schema.AnyOf, errors.Join(errs...))
|
||||
}
|
||||
}
|
||||
if schema.OneOf != nil {
|
||||
// Exactly one.
|
||||
var okSchema *Schema
|
||||
for _, ss := range schema.OneOf {
|
||||
if valid(ss, &anns) {
|
||||
if okSchema != nil {
|
||||
return fmt.Errorf("oneOf: validated against both %v and %v", okSchema, ss)
|
||||
}
|
||||
okSchema = ss
|
||||
}
|
||||
}
|
||||
if okSchema == nil {
|
||||
return fmt.Errorf("oneOf: did not validate against any of %v", schema.OneOf)
|
||||
}
|
||||
}
|
||||
if schema.Not != nil {
|
||||
// Ignore annotations from "not".
|
||||
if valid(schema.Not, nil) {
|
||||
return fmt.Errorf("not: validated against %v", schema.Not)
|
||||
}
|
||||
}
|
||||
if schema.If != nil {
|
||||
var ss *Schema
|
||||
if valid(schema.If, &anns) {
|
||||
ss = schema.Then
|
||||
} else {
|
||||
ss = schema.Else
|
||||
}
|
||||
if ss != nil {
|
||||
if err := st.validate(instance, ss, &anns); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// arrays
|
||||
if instance.Kind() == reflect.Array || instance.Kind() == reflect.Slice {
|
||||
// Handle both draft-07 and draft 2020-12
|
||||
// https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.1
|
||||
// This validate call doesn't collect annotations for the items of the instance; they are separate
|
||||
// instances in their own right.
|
||||
// TODO(jba): if the test suite doesn't cover this case, add a test. For example, nested arrays.
|
||||
if st.rs.draft == draft7 {
|
||||
// For draft-07: additionalItems applies to remaining items after items array.
|
||||
// If items is a Schema or if items is not set, additionalItems should be ignored
|
||||
if schema.ItemsArray != nil {
|
||||
for i, ischema := range schema.ItemsArray {
|
||||
if i >= instance.Len() {
|
||||
break // shorter is OK
|
||||
}
|
||||
if err := st.validate(instance.Index(i), ischema, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
anns.noteEndIndex(min(len(schema.ItemsArray), instance.Len()))
|
||||
if schema.AdditionalItems != nil {
|
||||
for i := len(schema.ItemsArray); i < instance.Len(); i++ {
|
||||
if err := st.validate(instance.Index(i), schema.AdditionalItems, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
anns.allItems = true
|
||||
}
|
||||
} else if schema.Items != nil {
|
||||
for i := 0; i < instance.Len(); i++ {
|
||||
if err := st.validate(instance.Index(i), schema.Items, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Note that all the items in this array have been validated.
|
||||
anns.allItems = true
|
||||
}
|
||||
} else if st.rs.draft == draft2020 {
|
||||
// For draft 2020-12: items applies to remaining items after prefixItems
|
||||
for i, ischema := range schema.PrefixItems {
|
||||
if i >= instance.Len() {
|
||||
break // shorter is OK
|
||||
}
|
||||
if err := st.validate(instance.Index(i), ischema, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
anns.noteEndIndex(min(len(schema.PrefixItems), instance.Len()))
|
||||
if schema.Items != nil {
|
||||
for i := len(schema.PrefixItems); i < instance.Len(); i++ {
|
||||
if err := st.validate(instance.Index(i), schema.Items, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Note that all the items in this array have been validated.
|
||||
anns.allItems = true
|
||||
}
|
||||
}
|
||||
nContains := 0
|
||||
if schema.Contains != nil {
|
||||
for i := range instance.Len() {
|
||||
if err := st.validate(instance.Index(i), schema.Contains, nil); err == nil {
|
||||
nContains++
|
||||
anns.noteIndex(i)
|
||||
}
|
||||
}
|
||||
if nContains == 0 && (schema.MinContains == nil || *schema.MinContains > 0) {
|
||||
return fmt.Errorf("contains: %s does not have an item matching %s", instance, schema.Contains)
|
||||
}
|
||||
}
|
||||
|
||||
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.4
|
||||
// TODO(jba): check that these next four keywords' values are integers.
|
||||
if schema.MinContains != nil && schema.Contains != nil {
|
||||
if m := *schema.MinContains; nContains < m {
|
||||
return fmt.Errorf("minContains: contains validated %d items, less than %d", nContains, m)
|
||||
}
|
||||
}
|
||||
if schema.MaxContains != nil && schema.Contains != nil {
|
||||
if m := *schema.MaxContains; nContains > m {
|
||||
return fmt.Errorf("maxContains: contains validated %d items, greater than %d", nContains, m)
|
||||
}
|
||||
}
|
||||
if schema.MinItems != nil {
|
||||
if m := *schema.MinItems; instance.Len() < m {
|
||||
return fmt.Errorf("minItems: array length %d is less than %d", instance.Len(), m)
|
||||
}
|
||||
}
|
||||
if schema.MaxItems != nil {
|
||||
if m := *schema.MaxItems; instance.Len() > m {
|
||||
return fmt.Errorf("maxItems: array length %d is greater than %d", instance.Len(), m)
|
||||
}
|
||||
}
|
||||
if schema.UniqueItems {
|
||||
if instance.Len() > 1 {
|
||||
// Hash each item and compare the hashes.
|
||||
// If two hashes differ, the items differ.
|
||||
// If two hashes are the same, compare the collisions for equality.
|
||||
// (The same logic as hash table lookup.)
|
||||
// TODO(jba): Use container/hash.Map when it becomes available (https://go.dev/issue/69559),
|
||||
hashes := map[uint64][]int{} // from hash to indices
|
||||
seed := maphash.MakeSeed()
|
||||
for i := range instance.Len() {
|
||||
item := instance.Index(i)
|
||||
var h maphash.Hash
|
||||
h.SetSeed(seed)
|
||||
hashValue(&h, item)
|
||||
hv := h.Sum64()
|
||||
if sames := hashes[hv]; len(sames) > 0 {
|
||||
for _, j := range sames {
|
||||
if equalValue(item, instance.Index(j)) {
|
||||
return fmt.Errorf("uniqueItems: array items %d and %d are equal", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
hashes[hv] = append(hashes[hv], i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://json-schema.org/draft/2020-12/json-schema-core#section-11.2
|
||||
if schema.UnevaluatedItems != nil && !anns.allItems {
|
||||
// Apply this subschema to all items in the array that haven't been successfully validated.
|
||||
// That includes validations by subschemas on the same instance, like allOf.
|
||||
for i := anns.endIndex; i < instance.Len(); i++ {
|
||||
if !anns.evaluatedIndexes[i] {
|
||||
if err := st.validate(instance.Index(i), schema.UnevaluatedItems, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
anns.allItems = true
|
||||
}
|
||||
}
|
||||
|
||||
// objects
|
||||
// https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.2
|
||||
// Validating structs is problematic. See https://github.com/google/jsonschema-go/issues/23.
|
||||
if instance.Kind() == reflect.Struct {
|
||||
return errors.New("cannot validate against a struct; see https://github.com/google/jsonschema-go/issues/23 for details")
|
||||
}
|
||||
if instance.Kind() == reflect.Map {
|
||||
if kt := instance.Type().Key(); kt.Kind() != reflect.String {
|
||||
return fmt.Errorf("map key type %s is not a string", kt)
|
||||
}
|
||||
// Track the evaluated properties for just this schema, to support additionalProperties.
|
||||
// If we used anns here, then we'd be including properties evaluated in subschemas
|
||||
// from allOf, etc., which additionalProperties shouldn't observe.
|
||||
evalProps := map[string]bool{}
|
||||
for prop, subschema := range schema.Properties {
|
||||
val := property(instance, prop)
|
||||
if !val.IsValid() {
|
||||
// It's OK if the instance doesn't have the property.
|
||||
continue
|
||||
}
|
||||
// If the instance is a struct and an optional property has the zero
|
||||
// value, then we could interpret it as present or missing. Be generous:
|
||||
// assume it's missing, and thus always validates successfully.
|
||||
if instance.Kind() == reflect.Struct && val.IsZero() && !schemaInfo.isRequired[prop] {
|
||||
continue
|
||||
}
|
||||
if err := st.validate(val, subschema, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
evalProps[prop] = true
|
||||
}
|
||||
if len(schema.PatternProperties) > 0 {
|
||||
for prop, val := range properties(instance) {
|
||||
// Check every matching pattern.
|
||||
for re, schema := range schemaInfo.patternProperties {
|
||||
if re.MatchString(prop) {
|
||||
if err := st.validate(val, schema, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
evalProps[prop] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if schema.AdditionalProperties != nil {
|
||||
// Special case for a better error message when additional properties is
|
||||
// 'falsy'
|
||||
//
|
||||
// If additionalProperties is {"not":{}} (which is how we
|
||||
// unmarshal "false"), we can produce a better error message that
|
||||
// summarizes all the extra properties. Otherwise, we fall back to the
|
||||
// default validation.
|
||||
//
|
||||
// Note: this is much faster than comparing with falseSchema using Equal.
|
||||
isFalsy := schema.AdditionalProperties.Not != nil && reflect.ValueOf(*schema.AdditionalProperties.Not).IsZero()
|
||||
if isFalsy {
|
||||
var disallowed []string
|
||||
for prop := range properties(instance) {
|
||||
if !evalProps[prop] {
|
||||
disallowed = append(disallowed, prop)
|
||||
}
|
||||
}
|
||||
if len(disallowed) > 0 {
|
||||
return fmt.Errorf("unexpected additional properties %q", disallowed)
|
||||
}
|
||||
} else {
|
||||
// Apply to all properties not handled above.
|
||||
for prop, val := range properties(instance) {
|
||||
if !evalProps[prop] {
|
||||
if err := st.validate(val, schema.AdditionalProperties, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
evalProps[prop] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
anns.noteProperties(evalProps)
|
||||
if schema.PropertyNames != nil {
|
||||
// Note: properties unnecessarily fetches each value. We could define a propertyNames function
|
||||
// if performance ever matters.
|
||||
for prop := range properties(instance) {
|
||||
if err := st.validate(reflect.ValueOf(prop), schema.PropertyNames, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.5
|
||||
var min, max int
|
||||
if schema.MinProperties != nil || schema.MaxProperties != nil {
|
||||
min, max = numPropertiesBounds(instance, schemaInfo.isRequired)
|
||||
}
|
||||
if schema.MinProperties != nil {
|
||||
if n, m := max, *schema.MinProperties; n < m {
|
||||
return fmt.Errorf("minProperties: object has %d properties, less than %d", n, m)
|
||||
}
|
||||
}
|
||||
if schema.MaxProperties != nil {
|
||||
if n, m := min, *schema.MaxProperties; n > m {
|
||||
return fmt.Errorf("maxProperties: object has %d properties, greater than %d", n, m)
|
||||
}
|
||||
}
|
||||
|
||||
hasProperty := func(prop string) bool {
|
||||
return property(instance, prop).IsValid()
|
||||
}
|
||||
|
||||
missingProperties := func(props []string) []string {
|
||||
var missing []string
|
||||
for _, p := range props {
|
||||
if !hasProperty(p) {
|
||||
missing = append(missing, p)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
if schema.Required != nil {
|
||||
if m := missingProperties(schema.Required); len(m) > 0 {
|
||||
return fmt.Errorf("required: missing properties: %q", m)
|
||||
}
|
||||
}
|
||||
|
||||
if st.rs.draft == draft7 {
|
||||
if schema.DependencyStrings != nil {
|
||||
for dprop, dstrings := range schema.DependencyStrings {
|
||||
if hasProperty(dprop) {
|
||||
if m := missingProperties(dstrings); len(m) > 0 {
|
||||
return fmt.Errorf("dependentRequired[%q]: missing properties %q", dprop, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if schema.DependencySchemas != nil {
|
||||
for dprop, dschema := range schema.DependencySchemas {
|
||||
if hasProperty(dprop) {
|
||||
err := st.validate(instance, dschema, &anns)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if st.rs.draft == draft2020 {
|
||||
if schema.DependentRequired != nil {
|
||||
// "Validation succeeds if, for each name that appears in both the instance
|
||||
// and as a name within this keyword's value, every item in the corresponding
|
||||
// array is also the name of a property in the instance." §6.5.4
|
||||
for dprop, reqs := range schema.DependentRequired {
|
||||
if hasProperty(dprop) {
|
||||
if m := missingProperties(reqs); len(m) > 0 {
|
||||
return fmt.Errorf("dependentRequired[%q]: missing properties %q", dprop, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://json-schema.org/draft/2020-12/json-schema-core#section-10.2.2.4
|
||||
if schema.DependentSchemas != nil {
|
||||
// This does not collect annotations, although it seems like it should.
|
||||
for dprop, ss := range schema.DependentSchemas {
|
||||
if hasProperty(dprop) {
|
||||
// TODO: include dependentSchemas[dprop] in the errors.
|
||||
err := st.validate(instance, ss, &anns)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if schema.UnevaluatedProperties != nil && !anns.allProperties {
|
||||
// This looks a lot like AdditionalProperties, but depends on in-place keywords like allOf
|
||||
// in addition to sibling keywords.
|
||||
for prop, val := range properties(instance) {
|
||||
if !anns.evaluatedProperties[prop] {
|
||||
if err := st.validate(val, schema.UnevaluatedProperties, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
// The spec says the annotation should be the set of evaluated properties, but we can optimize
|
||||
// by setting a single boolean, since after this succeeds all properties will be validated.
|
||||
// See https://json-schema.slack.com/archives/CT7FF623C/p1745592564381459.
|
||||
anns.allProperties = true
|
||||
}
|
||||
}
|
||||
|
||||
if callerAnns != nil {
|
||||
// Our caller wants to know what we've validated.
|
||||
callerAnns.merge(&anns)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveDynamicRef returns the schema referred to by the argument schema's
|
||||
// $dynamicRef value.
|
||||
// It returns an error if the dynamic reference has no referent.
|
||||
// If there is no $dynamicRef, resolveDynamicRef returns nil, nil.
|
||||
// See https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.3.2.
|
||||
func (st *state) resolveDynamicRef(schema *Schema) (*Schema, error) {
|
||||
if schema.DynamicRef == "" {
|
||||
return nil, nil
|
||||
}
|
||||
info := st.rs.resolvedInfos[schema]
|
||||
// The ref behaves lexically or dynamically, but not both.
|
||||
assert((info.resolvedDynamicRef == nil) != (info.dynamicRefAnchor == ""),
|
||||
"DynamicRef not statically resolved properly")
|
||||
if r := info.resolvedDynamicRef; r != nil {
|
||||
// Same as $ref.
|
||||
return r, nil
|
||||
}
|
||||
// Dynamic behavior.
|
||||
// Look for the base of the outermost schema on the stack with this dynamic
|
||||
// anchor. (Yes, outermost: the one farthest from here. This the opposite
|
||||
// of how ordinary dynamic variables behave.)
|
||||
// Why the base of the schema being validated and not the schema itself?
|
||||
// Because the base is the scope for anchors. In fact it's possible to
|
||||
// refer to a schema that is not on the stack, but a child of some base
|
||||
// on the stack.
|
||||
// For an example, search for "detached" in testdata/draft2020-12/dynamicRef.json.
|
||||
for _, s := range st.stack {
|
||||
base := st.rs.resolvedInfos[s].base
|
||||
info, ok := st.rs.resolvedInfos[base].anchors[info.dynamicRefAnchor]
|
||||
if ok && info.dynamic {
|
||||
return info.schema, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("missing dynamic anchor %q", info.dynamicRefAnchor)
|
||||
}
|
||||
|
||||
// ApplyDefaults modifies an instance by applying the schema's defaults to it. If
|
||||
// a schema or sub-schema has a default, then a corresponding missing instance value
|
||||
// is set to the default.
|
||||
//
|
||||
// The JSON Schema specification does not describe how defaults should be interpreted.
|
||||
// This method honors defaults only on properties, and only those that are not required.
|
||||
// If the instance is a map and the property is missing, the property is added to
|
||||
// the map with the default.
|
||||
// ApplyDefaults does not support structs, because it cannot know whether a field
|
||||
// is missing in the JSON, or was explicitly set to its zero value.
|
||||
//
|
||||
// ApplyDefaults can panic if a default cannot be assigned to a field.
|
||||
//
|
||||
// The argument must be a pointer to the instance.
|
||||
// (In case we decide that top-level defaults are meaningful.)
|
||||
//
|
||||
// It is recommended to first call Resolve with a ValidateDefaults option of true,
|
||||
// then call this method, and lastly call Validate.
|
||||
func (rs *Resolved) ApplyDefaults(instancep any) error {
|
||||
// TODO(jba): consider what defaults on top-level or array instances might mean.
|
||||
// TODO(jba): follow $ref and $dynamicRef
|
||||
st := &state{rs: rs}
|
||||
return st.applyDefaults(reflect.ValueOf(instancep), rs.root)
|
||||
}
|
||||
|
||||
// Recursive helper used by ApplyDefaults. Applies defaults on sub-schemas
|
||||
// of object properties recursively.
|
||||
func (st *state) applyDefaults(instancep reflect.Value, schema *Schema) (err error) {
|
||||
defer wrapf(&err, "applyDefaults: schema %s, instance %v", st.rs.schemaString(schema), instancep)
|
||||
|
||||
schemaInfo := st.rs.resolvedInfos[schema]
|
||||
instance := instancep.Elem()
|
||||
if instance.Kind() == reflect.Interface && instance.IsValid() {
|
||||
// If we unmarshalled into 'any', the default object unmarshalling will be map[string]any.
|
||||
instance = instance.Elem()
|
||||
}
|
||||
if instance.Kind() == reflect.Map || instance.Kind() == reflect.Struct {
|
||||
if instance.Kind() == reflect.Map {
|
||||
if kt := instance.Type().Key(); kt.Kind() != reflect.String {
|
||||
return fmt.Errorf("map key type %s is not a string", kt)
|
||||
}
|
||||
}
|
||||
for prop, subschema := range schema.Properties {
|
||||
// Ignore defaults on required properties. (A required property shouldn't have a default.)
|
||||
if schemaInfo.isRequired[prop] {
|
||||
continue
|
||||
}
|
||||
val := property(instance, prop)
|
||||
switch instance.Kind() {
|
||||
case reflect.Map:
|
||||
// If there is a default for this property, and the map key is missing,
|
||||
// set the map value to the default.
|
||||
if subschema.Default != nil && !val.IsValid() {
|
||||
// Create an lvalue, since map values aren't addressable.
|
||||
lvalue := reflect.New(instance.Type().Elem())
|
||||
if err := json.Unmarshal(subschema.Default, lvalue.Interface()); err != nil {
|
||||
return err
|
||||
}
|
||||
// Recurse unconditionally; applyDefaults will only act on object-like values.
|
||||
if err := st.applyDefaults(lvalue, subschema); err != nil {
|
||||
return err
|
||||
}
|
||||
instance.SetMapIndex(reflect.ValueOf(prop), lvalue.Elem())
|
||||
} else if val.IsValid() {
|
||||
// Recurse into an existing sub-instance.
|
||||
// MapIndex returns a non-addressable value; copy into an addressable lvalue, recurse, then set back.
|
||||
lvalue := reflect.New(instance.Type().Elem())
|
||||
// Initialize the lvalue with current value.
|
||||
lvalue.Elem().Set(val)
|
||||
if err := st.applyDefaults(lvalue, subschema); err != nil {
|
||||
return err
|
||||
}
|
||||
instance.SetMapIndex(reflect.ValueOf(prop), lvalue.Elem())
|
||||
} else if schemaHasDefaultsInProperties(subschema) {
|
||||
// Property is missing, but descendants still have some defaults
|
||||
// Create an empty container and recurse to populate
|
||||
elemType := instance.Type().Elem()
|
||||
var child reflect.Value
|
||||
switch elemType.Kind() {
|
||||
case reflect.Interface:
|
||||
child = reflect.ValueOf(map[string]any{})
|
||||
case reflect.Map:
|
||||
child = reflect.MakeMap(elemType)
|
||||
case reflect.Struct:
|
||||
child = reflect.New(elemType).Elem()
|
||||
}
|
||||
if child.IsValid() {
|
||||
lvalue := reflect.New(elemType)
|
||||
lvalue.Elem().Set(child)
|
||||
if err := st.applyDefaults(lvalue, subschema); err != nil {
|
||||
return err
|
||||
}
|
||||
instance.SetMapIndex(reflect.ValueOf(prop), lvalue.Elem())
|
||||
}
|
||||
}
|
||||
case reflect.Struct:
|
||||
return errors.New("cannot apply defaults to a struct")
|
||||
default:
|
||||
panic(fmt.Sprintf("applyDefaults: property %s: bad value %s of kind %s",
|
||||
prop, instance, instance.Kind()))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// schemaHasDefaultsInProperties reports whether s or any descendant schema under
|
||||
// its Properties contains a default. Only walks Properties to match ApplyDefaults semantics.
|
||||
func schemaHasDefaultsInProperties(s *Schema) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
if s.Default != nil {
|
||||
return true
|
||||
}
|
||||
if s.Properties != nil {
|
||||
for _, ss := range s.Properties {
|
||||
if schemaHasDefaultsInProperties(ss) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// property returns the value of the property of v with the given name, or the invalid
|
||||
// reflect.Value if there is none.
|
||||
// If v is a map, the property is the value of the map whose key is name.
|
||||
// If v is a struct, the property is the value of the field with the given name according
|
||||
// to the encoding/json package (see [jsonName]).
|
||||
// If v is anything else, property panics.
|
||||
func property(v reflect.Value, name string) reflect.Value {
|
||||
switch v.Kind() {
|
||||
case reflect.Map:
|
||||
return v.MapIndex(reflect.ValueOf(name))
|
||||
case reflect.Struct:
|
||||
props := structPropertiesOf(v.Type())
|
||||
// Ignore nonexistent properties.
|
||||
if sf, ok := props[name]; ok {
|
||||
return v.FieldByIndex(sf.Index)
|
||||
}
|
||||
return reflect.Value{}
|
||||
default:
|
||||
panic(fmt.Sprintf("property(%q): bad value %s of kind %s", name, v, v.Kind()))
|
||||
}
|
||||
}
|
||||
|
||||
// properties returns an iterator over the names and values of all properties
|
||||
// in v, which must be a map or a struct.
|
||||
// If a struct, zero-valued properties that are marked omitempty or omitzero
|
||||
// are excluded.
|
||||
func properties(v reflect.Value) iter.Seq2[string, reflect.Value] {
|
||||
return func(yield func(string, reflect.Value) bool) {
|
||||
switch v.Kind() {
|
||||
case reflect.Map:
|
||||
for k, e := range v.Seq2() {
|
||||
if !yield(k.String(), e) {
|
||||
return
|
||||
}
|
||||
}
|
||||
case reflect.Struct:
|
||||
for name, sf := range structPropertiesOf(v.Type()) {
|
||||
val := v.FieldByIndex(sf.Index)
|
||||
if val.IsZero() {
|
||||
info := fieldJSONInfo(sf)
|
||||
if info.settings["omitempty"] || info.settings["omitzero"] {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !yield(name, val) {
|
||||
return
|
||||
}
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("bad value %s of kind %s", v, v.Kind()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// numPropertiesBounds returns bounds on the number of v's properties.
|
||||
// v must be a map or a struct.
|
||||
// If v is a map, both bounds are the map's size.
|
||||
// If v is a struct, the max is the number of struct properties.
|
||||
// But since we don't know whether a zero value indicates a missing optional property
|
||||
// or not, be generous and use the number of non-zero properties as the min.
|
||||
func numPropertiesBounds(v reflect.Value, isRequired map[string]bool) (int, int) {
|
||||
switch v.Kind() {
|
||||
case reflect.Map:
|
||||
return v.Len(), v.Len()
|
||||
case reflect.Struct:
|
||||
sp := structPropertiesOf(v.Type())
|
||||
min := 0
|
||||
for prop, sf := range sp {
|
||||
if !v.FieldByIndex(sf.Index).IsZero() || isRequired[prop] {
|
||||
min++
|
||||
}
|
||||
}
|
||||
return min, len(sp)
|
||||
default:
|
||||
panic(fmt.Sprintf("properties: bad value: %s of kind %s", v, v.Kind()))
|
||||
}
|
||||
}
|
||||
|
||||
// A propertyMap is a map from property name to struct field index.
|
||||
type propertyMap = map[string]reflect.StructField
|
||||
|
||||
var structProperties sync.Map // from reflect.Type to propertyMap
|
||||
|
||||
// structPropertiesOf returns the JSON Schema properties for the struct type t.
|
||||
// The caller must not mutate the result.
|
||||
func structPropertiesOf(t reflect.Type) propertyMap {
|
||||
// Mutex not necessary: at worst we'll recompute the same value.
|
||||
if props, ok := structProperties.Load(t); ok {
|
||||
return props.(propertyMap)
|
||||
}
|
||||
props := map[string]reflect.StructField{}
|
||||
for _, sf := range reflect.VisibleFields(t) {
|
||||
if sf.Anonymous {
|
||||
continue
|
||||
}
|
||||
info := fieldJSONInfo(sf)
|
||||
if !info.omit {
|
||||
props[info.name] = sf
|
||||
}
|
||||
}
|
||||
structProperties.Store(t, props)
|
||||
return props
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0.
|
||||
|
||||
Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License.
|
||||
|
||||
No rights beyond those granted by the applicable original license are conveyed for such contributions.
|
||||
|
||||
---
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright
|
||||
owner or by an individual or Legal Entity authorized to submit on behalf
|
||||
of the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
---
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
Creative Commons Attribution 4.0 International (CC-BY-4.0)
|
||||
|
||||
Documentation in this project (excluding specifications) is licensed under
|
||||
CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for
|
||||
the full license text.
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||
)
|
||||
|
||||
// TokenInfo holds information from a bearer token.
|
||||
type TokenInfo struct {
|
||||
Scopes []string
|
||||
Expiration time.Time
|
||||
// UserID is an optional identifier for the authenticated user.
|
||||
// If set by a TokenVerifier, it can be used by transports to prevent
|
||||
// session hijacking by ensuring that all requests for a given session
|
||||
// come from the same user.
|
||||
UserID string
|
||||
Extra map[string]any
|
||||
}
|
||||
|
||||
// The error that a TokenVerifier should return if the token cannot be verified.
|
||||
var ErrInvalidToken = errors.New("invalid token")
|
||||
|
||||
// The error that a TokenVerifier should return for OAuth-specific protocol errors.
|
||||
var ErrOAuth = errors.New("oauth error")
|
||||
|
||||
// A TokenVerifier checks the validity of a bearer token, and extracts information
|
||||
// from it. If verification fails, it should return an error that unwraps to ErrInvalidToken.
|
||||
// The HTTP request is provided in case verifying the token involves checking it.
|
||||
type TokenVerifier func(ctx context.Context, token string, req *http.Request) (*TokenInfo, error)
|
||||
|
||||
// RequireBearerTokenOptions are options for [RequireBearerToken].
|
||||
type RequireBearerTokenOptions struct {
|
||||
// The URL for the resource server metadata OAuth flow, to be returned as part
|
||||
// of the WWW-Authenticate header.
|
||||
ResourceMetadataURL string
|
||||
// The required scopes.
|
||||
Scopes []string
|
||||
// AllowMissingExpiration opts the middleware out of the
|
||||
// `tokenInfo.Expiration.IsZero()` reject. Default false preserves the
|
||||
// existing strict behaviour (every TokenInfo must carry an Expiration).
|
||||
//
|
||||
// Some IdPs emit session-bound bearer tokens that do not carry a standalone
|
||||
// `exp` claim — the token's lifetime is bounded by an external session and
|
||||
// is not advertised in-band. Resource servers integrating with such IdPs
|
||||
// need to opt in to validating the rest of the token (scopes, signature
|
||||
// via the verifier callback, etc.) without requiring the expiration field
|
||||
// to be present.
|
||||
//
|
||||
// When enabled, the verifier is still responsible for any session-level
|
||||
// validity check it can perform; this option only relaxes the middleware's
|
||||
// own expiration enforcement.
|
||||
AllowMissingExpiration bool
|
||||
// ClockSkew bounds the tolerance applied to a token's Expiration when
|
||||
// deciding whether it has elapsed. A token is rejected only if
|
||||
// Expiration + ClockSkew is before the current time. Zero (the default)
|
||||
// preserves strict comparison: any expired token is rejected immediately.
|
||||
//
|
||||
// Resource servers running behind a CDN, in distributed deployments, or
|
||||
// communicating with an authorization server whose clock drifts a few
|
||||
// seconds (common with cloud-managed IdPs) need a small positive value
|
||||
// here to avoid rejecting tokens that are valid by the issuer's clock
|
||||
// but momentarily appear expired by the verifier's. The same tolerance
|
||||
// guards against an issuer's clock running slightly fast at /token
|
||||
// issuance time.
|
||||
ClockSkew time.Duration
|
||||
}
|
||||
|
||||
type tokenInfoKey struct{}
|
||||
|
||||
// TokenInfoFromContext returns the [TokenInfo] stored in ctx, or nil if none.
|
||||
func TokenInfoFromContext(ctx context.Context) *TokenInfo {
|
||||
ti := ctx.Value(tokenInfoKey{})
|
||||
if ti == nil {
|
||||
return nil
|
||||
}
|
||||
return ti.(*TokenInfo)
|
||||
}
|
||||
|
||||
// RequireBearerToken returns a piece of middleware that verifies a bearer token using the verifier.
|
||||
// If verification succeeds, the [TokenInfo] is added to the request's context and the request proceeds.
|
||||
// If verification fails, the request fails with a 401 Unauthenticated, and the WWW-Authenticate header
|
||||
// is populated to enable [protected resource metadata].
|
||||
//
|
||||
// [protected resource metadata]: https://datatracker.ietf.org/doc/rfc9728
|
||||
func RequireBearerToken(verifier TokenVerifier, opts *RequireBearerTokenOptions) func(http.Handler) http.Handler {
|
||||
// Based on typescript-sdk/src/server/auth/middleware/bearerAuth.ts.
|
||||
|
||||
return func(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tokenInfo, errmsg, code := verify(r, verifier, opts)
|
||||
if code != 0 {
|
||||
if code == http.StatusUnauthorized || code == http.StatusForbidden {
|
||||
if opts != nil {
|
||||
var params []string
|
||||
if opts.ResourceMetadataURL != "" {
|
||||
params = append(params, fmt.Sprintf("resource_metadata=%q", opts.ResourceMetadataURL))
|
||||
}
|
||||
if len(opts.Scopes) > 0 {
|
||||
params = append(params, fmt.Sprintf("scope=%q", strings.Join(opts.Scopes, " ")))
|
||||
}
|
||||
if len(params) > 0 {
|
||||
w.Header().Add("WWW-Authenticate", "Bearer "+strings.Join(params, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
http.Error(w, errmsg, code)
|
||||
return
|
||||
}
|
||||
r = r.WithContext(context.WithValue(r.Context(), tokenInfoKey{}, tokenInfo))
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func verify(req *http.Request, verifier TokenVerifier, opts *RequireBearerTokenOptions) (_ *TokenInfo, errmsg string, code int) {
|
||||
// Extract bearer token.
|
||||
authHeader := req.Header.Get("Authorization")
|
||||
fields := strings.Fields(authHeader)
|
||||
if len(fields) != 2 || strings.ToLower(fields[0]) != "bearer" {
|
||||
return nil, "no bearer token", http.StatusUnauthorized
|
||||
}
|
||||
|
||||
// Verify the token and get information from it.
|
||||
tokenInfo, err := verifier(req.Context(), fields[1], req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidToken) {
|
||||
return nil, err.Error(), http.StatusUnauthorized
|
||||
}
|
||||
if errors.Is(err, ErrOAuth) {
|
||||
return nil, err.Error(), http.StatusBadRequest
|
||||
}
|
||||
return nil, err.Error(), http.StatusInternalServerError
|
||||
}
|
||||
if tokenInfo == nil {
|
||||
return nil, "token validation failed", http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// Check scopes. All must be present.
|
||||
if opts != nil {
|
||||
// Note: quadratic, but N is small.
|
||||
for _, s := range opts.Scopes {
|
||||
if !slices.Contains(tokenInfo.Scopes, s) {
|
||||
return nil, "insufficient scope", http.StatusForbidden
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if opts == nil {
|
||||
opts = &RequireBearerTokenOptions{}
|
||||
}
|
||||
// Check expiration, with optional clock-skew tolerance. Skew only applies
|
||||
// when an expiration is present; a missing expiration is governed solely by
|
||||
// AllowMissingExpiration.
|
||||
if tokenInfo.Expiration.IsZero() {
|
||||
if !opts.AllowMissingExpiration {
|
||||
return nil, "token missing expiration", http.StatusUnauthorized
|
||||
}
|
||||
} else if tokenInfo.Expiration.Add(opts.ClockSkew).Before(time.Now()) {
|
||||
return nil, "token expired", http.StatusUnauthorized
|
||||
}
|
||||
return tokenInfo, "", 0
|
||||
}
|
||||
|
||||
// ProtectedResourceMetadataHandler returns an http.Handler that serves OAuth 2.0
|
||||
// protected resource metadata (RFC 9728) with CORS support.
|
||||
//
|
||||
// This handler allows cross-origin requests from any origin (Access-Control-Allow-Origin: *)
|
||||
// because OAuth metadata is public information intended for client discovery (RFC 9728 §3.1).
|
||||
// The metadata contains only non-sensitive configuration data about authorization servers
|
||||
// and supported scopes.
|
||||
//
|
||||
// No validation of metadata fields is performed; ensure metadata accuracy at configuration time.
|
||||
//
|
||||
// For more sophisticated CORS policies or to restrict origins, wrap this handler with a
|
||||
// CORS middleware like github.com/rs/cors or github.com/jub0bs/cors.
|
||||
func ProtectedResourceMetadataHandler(metadata *oauthex.ProtectedResourceMetadata) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Set CORS headers for cross-origin client discovery.
|
||||
// OAuth metadata is public information, so allowing any origin is safe.
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
// Handle CORS preflight requests
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Only GET allowed for metadata retrieval
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(metadata); err != nil {
|
||||
http.Error(w, "Failed to encode metadata", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
+684
@@ -0,0 +1,684 @@
|
||||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by the license
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// ClientIDMetadataDocumentConfig is used to configure the Client ID Metadata Document
|
||||
// based client registration per
|
||||
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents.
|
||||
// See https://client.dev/ for more information.
|
||||
type ClientIDMetadataDocumentConfig struct {
|
||||
// URL is the client identifier URL as per
|
||||
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#section-3.
|
||||
URL string
|
||||
}
|
||||
|
||||
// DynamicClientRegistrationConfig is used to configure dynamic client registration per
|
||||
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration.
|
||||
type DynamicClientRegistrationConfig struct {
|
||||
// Metadata to be used in dynamic client registration request as per
|
||||
// https://datatracker.ietf.org/doc/html/rfc7591#section-2.
|
||||
//
|
||||
// If Metadata.ApplicationType is empty, it will be inferred from
|
||||
// Metadata.RedirectURIs. When set, it will be validated against the inferred type
|
||||
// and an error will be returned if they conflict.
|
||||
Metadata *oauthex.ClientRegistrationMetadata
|
||||
}
|
||||
|
||||
// AuthorizationResult is the result of an authorization flow.
|
||||
// It is returned by [AuthorizationCodeHandler].AuthorizationCodeFetcher implementations.
|
||||
type AuthorizationResult struct {
|
||||
// Code is the authorization code obtained from the authorization server.
|
||||
Code string
|
||||
// State string returned by the authorization server.
|
||||
State string
|
||||
// Iss is the issuer identifier returned by the authorization server in the
|
||||
// authorization response per [RFC 9207]. The AuthorizationCodeFetcher should
|
||||
// populate this from the "iss" query parameter in the redirect URI if present.
|
||||
//
|
||||
// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207
|
||||
Iss string
|
||||
}
|
||||
|
||||
// AuthorizationArgs is the input to [AuthorizationCodeFetcher].
|
||||
type AuthorizationArgs struct {
|
||||
// Authorization URL to be opened in a browser for the user to start the authorization process.
|
||||
URL string
|
||||
}
|
||||
|
||||
// AuthorizationCodeFetcher is called to initiate the OAuth authorization flow.
|
||||
// It is responsible for directing the user to the authorization URL (e.g., opening
|
||||
// in a browser) and returning the authorization code and state once the Authorization
|
||||
// Server redirects back to the configured RedirectURL.
|
||||
type AuthorizationCodeFetcher func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error)
|
||||
|
||||
// AuthorizationCodeHandlerConfig is the configuration for [AuthorizationCodeHandler].
|
||||
type AuthorizationCodeHandlerConfig struct {
|
||||
// Client registration configuration.
|
||||
// It is attempted in the following order:
|
||||
// 1. Client ID Metadata Document
|
||||
// 2. Preregistration
|
||||
// 3. Dynamic Client Registration
|
||||
// At least one method must be configured.
|
||||
ClientIDMetadataDocumentConfig *ClientIDMetadataDocumentConfig
|
||||
PreregisteredClient *oauthex.ClientCredentials
|
||||
DynamicClientRegistrationConfig *DynamicClientRegistrationConfig
|
||||
|
||||
// RedirectURL is a required URL to redirect to after authorization.
|
||||
// The caller is responsible for handling the redirect out of band.
|
||||
//
|
||||
// If Dynamic Client Registration is used:
|
||||
// - this field is permitted to be empty, in which case it will be set
|
||||
// to the first redirect URI from
|
||||
// DynamicClientRegistrationConfig.Metadata.RedirectURIs.
|
||||
// - if the field is not empty, it must be one of the redirect URIs in
|
||||
// DynamicClientRegistrationConfig.Metadata.RedirectURIs.
|
||||
RedirectURL string
|
||||
|
||||
// AuthorizationCodeFetcher is a required function called to initiate the authorization flow.
|
||||
// See [AuthorizationCodeFetcher] for details.
|
||||
AuthorizationCodeFetcher AuthorizationCodeFetcher
|
||||
|
||||
// RequestRefreshToken indicates that the client intends to use refresh
|
||||
// tokens and is capable of storing them securely.
|
||||
//
|
||||
// When true and the Authorization Server metadata contains "offline_access"
|
||||
// in its scopes_supported, the client adds "offline_access" to the
|
||||
// requested scopes.
|
||||
//
|
||||
// When using Dynamic Client Registration, callers should include
|
||||
// "refresh_token" in [DynamicClientRegistrationConfig].Metadata.GrantTypes
|
||||
// directly to advertise refresh token support to the Authorization Server.
|
||||
//
|
||||
// When using Client ID Metadata Document, the document hosted at the
|
||||
// Client ID URL should include "refresh_token" in its grant_types.
|
||||
//
|
||||
// See https://modelcontextprotocol.io/seps/2207-oidc-refresh-token-guidance.
|
||||
RequestRefreshToken bool
|
||||
|
||||
// Client is an optional HTTP client to use for HTTP requests.
|
||||
// It is used for the following requests:
|
||||
// - Fetching Protected Resource Metadata
|
||||
// - Fetching Authorization Server Metadata
|
||||
// - Registering a client dynamically
|
||||
// - Exchanging an authorization code for an access token
|
||||
// - Refreshing an access token
|
||||
// Custom clients can include additional security configurations,
|
||||
// such as SSRF protections, see
|
||||
// https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#server-side-request-forgery-ssrf
|
||||
// If not provided, http.DefaultClient will be used.
|
||||
Client *http.Client
|
||||
|
||||
// NewTokenSource is an optional function that can be set to construct the
|
||||
// token source that will be used by the [AuthorizationCodeHandler]. If
|
||||
// non-nil, it is called after the authorization code is successfully
|
||||
// exchanged for a token in [AuthorizationCodeHandler.Authorize]
|
||||
// to obtain the [oauth2.TokenSource] returned by
|
||||
// [AuthorizationCodeHandler.TokenSource]. Implementations must use the
|
||||
// provided context, which is properly configured for constructing a
|
||||
// TokenSource. The default is to call [oauth2.Config.TokenSource].
|
||||
NewTokenSource func(context.Context, *oauth2.Config, *oauth2.Token) (oauth2.TokenSource, error)
|
||||
|
||||
// InitialTokenSource is an optional field that can be set to inject the
|
||||
// token source that will be used by the [AuthorizationCodeHandler]. If
|
||||
// non-nil, it is set as the token source that will be returned by
|
||||
// [AuthorizationCodeHandler.TokenSource] during handler initialization.
|
||||
// The default is nil, which means no token source has been set initially,
|
||||
// and will trigger a call to [AuthorizationCodeHandler.Authorize].
|
||||
InitialTokenSource oauth2.TokenSource
|
||||
}
|
||||
|
||||
// AuthorizationCodeHandler is an implementation of [OAuthHandler] that uses
|
||||
// the authorization code flow to obtain access tokens.
|
||||
type AuthorizationCodeHandler struct {
|
||||
config *AuthorizationCodeHandlerConfig
|
||||
|
||||
// mu protects concurrent access to tokenSource and grantedScopes.
|
||||
mu sync.RWMutex
|
||||
|
||||
// tokenSource is the token source to use for authorization.
|
||||
tokenSource oauth2.TokenSource
|
||||
|
||||
// grantedScopes maps authorization server issuer to the list of scopes granted by that issuer.
|
||||
grantedScopes map[string][]string
|
||||
}
|
||||
|
||||
var _ OAuthHandler = (*AuthorizationCodeHandler)(nil)
|
||||
|
||||
func (h *AuthorizationCodeHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.tokenSource, nil
|
||||
}
|
||||
|
||||
// NewAuthorizationCodeHandler creates a new AuthorizationCodeHandler.
|
||||
// It performs validation of the configuration and returns an error if it is invalid.
|
||||
// The passed config is consumed by the handler and should not be modified after.
|
||||
func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*AuthorizationCodeHandler, error) {
|
||||
if config == nil {
|
||||
return nil, errors.New("config must be provided")
|
||||
}
|
||||
if config.ClientIDMetadataDocumentConfig == nil &&
|
||||
config.PreregisteredClient == nil &&
|
||||
config.DynamicClientRegistrationConfig == nil {
|
||||
return nil, errors.New("at least one client registration configuration must be provided")
|
||||
}
|
||||
if config.AuthorizationCodeFetcher == nil {
|
||||
return nil, errors.New("AuthorizationCodeFetcher is required")
|
||||
}
|
||||
if config.ClientIDMetadataDocumentConfig != nil && !isNonRootHTTPSURL(config.ClientIDMetadataDocumentConfig.URL) {
|
||||
return nil, fmt.Errorf("client ID metadata document URL must be a non-root HTTPS URL")
|
||||
}
|
||||
if config.PreregisteredClient != nil {
|
||||
if err := config.PreregisteredClient.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid PreregisteredClient configuration: %w", err)
|
||||
}
|
||||
}
|
||||
dCfg := config.DynamicClientRegistrationConfig
|
||||
if dCfg != nil {
|
||||
if dCfg.Metadata == nil {
|
||||
return nil, errors.New("dynamic client registration requires non-nil Metadata")
|
||||
}
|
||||
if len(dCfg.Metadata.RedirectURIs) == 0 {
|
||||
return nil, errors.New("Metadata.RedirectURIs is required for dynamic client registration")
|
||||
}
|
||||
if config.RedirectURL == "" {
|
||||
config.RedirectURL = dCfg.Metadata.RedirectURIs[0]
|
||||
} else if !slices.Contains(dCfg.Metadata.RedirectURIs, config.RedirectURL) {
|
||||
return nil, fmt.Errorf("RedirectURL %q is not in the list of allowed redirect URIs for dynamic client registration", config.RedirectURL)
|
||||
}
|
||||
applicationType := inferApplicationType(dCfg.Metadata.RedirectURIs)
|
||||
if dCfg.Metadata.ApplicationType == "" {
|
||||
dCfg.Metadata.ApplicationType = applicationType
|
||||
} else if dCfg.Metadata.ApplicationType != applicationType {
|
||||
return nil, fmt.Errorf("application type %q conflicts with the application type inferred from redirect URIs", dCfg.Metadata.ApplicationType)
|
||||
}
|
||||
}
|
||||
if config.RedirectURL == "" {
|
||||
// If the RedirectURL was supposed to be set by the dynamic client registration,
|
||||
// it should have been set by now. Otherwise, it is required.
|
||||
return nil, errors.New("RedirectURL is required")
|
||||
}
|
||||
if config.Client == nil {
|
||||
config.Client = http.DefaultClient
|
||||
}
|
||||
return &AuthorizationCodeHandler{
|
||||
config: config,
|
||||
tokenSource: config.InitialTokenSource,
|
||||
grantedScopes: make(map[string][]string),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isNonRootHTTPSURL(u string) bool {
|
||||
pu, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return pu.Scheme == "https" && pu.Path != ""
|
||||
}
|
||||
|
||||
// inferApplicationType returns an application type based on the redirect URIs.
|
||||
func inferApplicationType(redirectURIs []string) string {
|
||||
hasNative := false
|
||||
hasWeb := false
|
||||
for _, uri := range redirectURIs {
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "http", "https":
|
||||
if util.IsLoopback(u.Hostname()) {
|
||||
hasNative = true
|
||||
} else {
|
||||
hasWeb = true
|
||||
}
|
||||
default:
|
||||
hasNative = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasNative && hasWeb {
|
||||
return ""
|
||||
}
|
||||
if hasNative {
|
||||
return "native"
|
||||
}
|
||||
return "web"
|
||||
}
|
||||
|
||||
// Authorize performs the authorization flow.
|
||||
// It is designed to perform the whole Authorization Code Grant flow.
|
||||
// On success, [AuthorizationCodeHandler.TokenSource] will return a token source with the fetched token.
|
||||
func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error {
|
||||
defer resp.Body.Close()
|
||||
defer io.Copy(io.Discard, resp.Body)
|
||||
|
||||
wwwChallenges, err := oauthex.ParseWWWAuthenticate(resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse WWW-Authenticate header: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden && errorFromChallenges(wwwChallenges) != "insufficient_scope" {
|
||||
// We only want to perform step-up authorization for insufficient_scope errors.
|
||||
// Returning nil, so that the call is retried immediately and the response
|
||||
// is handled appropriately by the connection.
|
||||
// Step-up authorization is defined at
|
||||
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#step-up-authorization-flow
|
||||
return nil
|
||||
}
|
||||
|
||||
prm, err := h.getProtectedResourceMetadata(ctx, wwwChallenges, req.URL.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
asm, err := GetAuthServerMetadata(ctx, prm.AuthorizationServers[0], h.config.Client)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get authorization server metadata: %w", err)
|
||||
}
|
||||
if asm == nil {
|
||||
// Fallback to 2025-03-26 spec: predefined endpoints.
|
||||
// https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
|
||||
authServerURL := prm.AuthorizationServers[0]
|
||||
asm = &oauthex.AuthServerMeta{
|
||||
Issuer: authServerURL,
|
||||
AuthorizationEndpoint: authServerURL + "/authorize",
|
||||
TokenEndpoint: authServerURL + "/token",
|
||||
RegistrationEndpoint: authServerURL + "/register",
|
||||
}
|
||||
}
|
||||
|
||||
resolvedClientConfig, err := h.handleRegistration(ctx, asm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestedScopes := scopesFromChallenges(wwwChallenges)
|
||||
if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 {
|
||||
requestedScopes = prm.ScopesSupported
|
||||
}
|
||||
|
||||
// SEP-2207: when the client desires refresh tokens and the Authorization
|
||||
// Server advertises offline_access support, add it to the requested scopes.
|
||||
if h.config.RequestRefreshToken &&
|
||||
slices.Contains(asm.ScopesSupported, "offline_access") &&
|
||||
!slices.Contains(requestedScopes, "offline_access") {
|
||||
requestedScopes = append(requestedScopes, "offline_access")
|
||||
}
|
||||
|
||||
// Accumulate scopes: union previously granted scopes with the newly
|
||||
// challenged scopes so that step-up authorization does not lose
|
||||
// permissions granted in earlier rounds (SEP-2350).
|
||||
h.mu.RLock()
|
||||
granted := h.grantedScopes[asm.Issuer]
|
||||
h.mu.RUnlock()
|
||||
requestedScopes = authutil.UnionScopes(granted, requestedScopes)
|
||||
|
||||
cfg := &oauth2.Config{
|
||||
ClientID: resolvedClientConfig.clientID,
|
||||
ClientSecret: resolvedClientConfig.clientSecret,
|
||||
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: asm.AuthorizationEndpoint,
|
||||
TokenURL: asm.TokenEndpoint,
|
||||
AuthStyle: resolvedClientConfig.authStyle,
|
||||
},
|
||||
RedirectURL: h.config.RedirectURL,
|
||||
Scopes: requestedScopes,
|
||||
}
|
||||
|
||||
authRes, err := h.getAuthorizationCode(ctx, cfg, prm.Resource)
|
||||
if err != nil {
|
||||
// Purposefully leaving the error unwrappable so it can be handled by the caller.
|
||||
return err
|
||||
}
|
||||
if err := validateIssuerResponse(authRes.Iss, asm.Issuer, asm.AuthorizationResponseIssParameterSupported); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.updateGrantedScopes(asm.Issuer, requestedScopes)
|
||||
}
|
||||
|
||||
// resourceMetadataURLFromChallenges returns a resource metadata URL from the given "WWW-Authenticate" header challenges,
|
||||
// or the empty string if there is none.
|
||||
func resourceMetadataURLFromChallenges(cs []oauthex.Challenge) string {
|
||||
for _, c := range cs {
|
||||
if u := c.Params["resource_metadata"]; u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// scopesFromChallenges returns the scopes from the given "WWW-Authenticate" header challenges.
|
||||
// It only looks at challenges with the "Bearer" scheme.
|
||||
func scopesFromChallenges(cs []oauthex.Challenge) []string {
|
||||
for _, c := range cs {
|
||||
if c.Scheme == "bearer" && c.Params["scope"] != "" {
|
||||
return strings.Fields(c.Params["scope"])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// errorFromChallenges returns the error from the given "WWW-Authenticate" header challenges.
|
||||
// It only looks at challenges with the "Bearer" scheme.
|
||||
func errorFromChallenges(cs []oauthex.Challenge) string {
|
||||
for _, c := range cs {
|
||||
if c.Scheme == "bearer" && c.Params["error"] != "" {
|
||||
return c.Params["error"]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getProtectedResourceMetadata returns the protected resource metadata.
|
||||
// If no metadata was found or the fetched metadata fails security checks,
|
||||
// it returns an error.
|
||||
func (h *AuthorizationCodeHandler) getProtectedResourceMetadata(ctx context.Context, wwwChallenges []oauthex.Challenge, mcpServerURL string) (*oauthex.ProtectedResourceMetadata, error) {
|
||||
// Use MCP server URL as the resource URI per
|
||||
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#canonical-server-uri.
|
||||
for _, url := range protectedResourceMetadataURLs(resourceMetadataURLFromChallenges(wwwChallenges), mcpServerURL) {
|
||||
prm, err := oauthex.GetProtectedResourceMetadata(ctx, url.URL, url.Resource, h.config.Client)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if prm == nil {
|
||||
continue
|
||||
}
|
||||
if len(prm.AuthorizationServers) == 0 {
|
||||
// If we found PRM, we enforce the 2025-11-25 spec and not search further.
|
||||
return nil, fmt.Errorf("protected resource metadata has no authorization servers specified")
|
||||
}
|
||||
return prm, nil
|
||||
}
|
||||
// Fallback to 2025-03-26 spec MCP server root is the Authorization Server:
|
||||
// https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#server-metadata-discovery
|
||||
u, err := url.Parse(mcpServerURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse MCP server URL: %v", err)
|
||||
}
|
||||
u.Path = ""
|
||||
prm := &oauthex.ProtectedResourceMetadata{
|
||||
AuthorizationServers: []string{u.String()},
|
||||
Resource: mcpServerURL,
|
||||
}
|
||||
return prm, nil
|
||||
}
|
||||
|
||||
type prmURL struct {
|
||||
// URL represents a URL where Protected Resource Metadata may be retrieved.
|
||||
URL string
|
||||
// Resource represents the corresponding resource URL for [URL].
|
||||
// It is required to perform validation described in RFC 9728, section 3.3.
|
||||
Resource string
|
||||
}
|
||||
|
||||
// protectedResourceMetadataURLs returns a list of URLs to try when looking for
|
||||
// protected resource metadata as mandated by the MCP specification:
|
||||
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements
|
||||
func protectedResourceMetadataURLs(metadataURL, resourceURL string) []prmURL {
|
||||
var urls []prmURL
|
||||
if metadataURL != "" {
|
||||
urls = append(urls, prmURL{
|
||||
URL: metadataURL,
|
||||
Resource: resourceURL,
|
||||
})
|
||||
}
|
||||
ru, err := url.Parse(resourceURL)
|
||||
if err != nil {
|
||||
return urls
|
||||
}
|
||||
mu := *ru
|
||||
// "At the path of the server's MCP endpoint".
|
||||
mu.Path = "/.well-known/oauth-protected-resource/" + strings.TrimLeft(ru.Path, "/")
|
||||
urls = append(urls, prmURL{
|
||||
URL: mu.String(),
|
||||
Resource: resourceURL,
|
||||
})
|
||||
// "At the root".
|
||||
mu.Path = "/.well-known/oauth-protected-resource"
|
||||
ru.Path = ""
|
||||
urls = append(urls, prmURL{
|
||||
URL: mu.String(),
|
||||
Resource: ru.String(),
|
||||
})
|
||||
return urls
|
||||
}
|
||||
|
||||
type registrationType int
|
||||
|
||||
const (
|
||||
registrationTypeClientIDMetadataDocument registrationType = iota
|
||||
registrationTypePreregistered
|
||||
registrationTypeDynamic
|
||||
)
|
||||
|
||||
type resolvedClientConfig struct {
|
||||
registrationType registrationType
|
||||
clientID string
|
||||
clientSecret string
|
||||
authStyle oauth2.AuthStyle
|
||||
}
|
||||
|
||||
func selectTokenAuthMethod(supported []string) oauth2.AuthStyle {
|
||||
prefOrder := []string{
|
||||
// Preferred in OAuth 2.1 draft: https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-14.html#name-client-secret.
|
||||
"client_secret_post",
|
||||
"client_secret_basic",
|
||||
}
|
||||
for _, method := range prefOrder {
|
||||
if slices.Contains(supported, method) {
|
||||
return authMethodToStyle(method)
|
||||
}
|
||||
}
|
||||
return oauth2.AuthStyleAutoDetect
|
||||
}
|
||||
|
||||
func authMethodToStyle(method string) oauth2.AuthStyle {
|
||||
switch method {
|
||||
case "client_secret_post":
|
||||
return oauth2.AuthStyleInParams
|
||||
case "client_secret_basic":
|
||||
return oauth2.AuthStyleInHeader
|
||||
case "none":
|
||||
// "none" is equivalent to "client_secret_post" but without sending client secret.
|
||||
return oauth2.AuthStyleInParams
|
||||
default:
|
||||
// "client_secret_basic" is the default per https://datatracker.ietf.org/doc/html/rfc7591#section-2.
|
||||
return oauth2.AuthStyleInHeader
|
||||
}
|
||||
}
|
||||
|
||||
// handleRegistration handles client registration.
|
||||
// The provided authorization server metadata must be non-nil.
|
||||
// Support for different registration methods is defined as follows:
|
||||
// - Client ID Metadata Document: metadata must have
|
||||
// `ClientIDMetadataDocumentSupported` set to true.
|
||||
// - Pre-registered client: assumed to be supported.
|
||||
// - Dynamic client registration: metadata must have
|
||||
// `RegistrationEndpoint` set to a non-empty value.
|
||||
func (h *AuthorizationCodeHandler) handleRegistration(ctx context.Context, asm *oauthex.AuthServerMeta) (*resolvedClientConfig, error) {
|
||||
// 1. Attempt to use Client ID Metadata Document (SEP-991).
|
||||
cimdCfg := h.config.ClientIDMetadataDocumentConfig
|
||||
if cimdCfg != nil && asm.ClientIDMetadataDocumentSupported {
|
||||
return &resolvedClientConfig{
|
||||
registrationType: registrationTypeClientIDMetadataDocument,
|
||||
clientID: cimdCfg.URL,
|
||||
}, nil
|
||||
}
|
||||
// 2. Attempt to use pre-registered client configuration.
|
||||
preCfg := h.config.PreregisteredClient
|
||||
if preCfg != nil {
|
||||
if preCfg.Issuer != "" && !authutil.IssuersEqual(preCfg.Issuer, asm.Issuer) {
|
||||
return nil, fmt.Errorf("authorization server issuer %q does not match pre-registered credentials issuer %q", asm.Issuer, preCfg.Issuer)
|
||||
}
|
||||
authStyle := selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported)
|
||||
clientSecret := ""
|
||||
if preCfg.ClientSecretAuth != nil {
|
||||
clientSecret = preCfg.ClientSecretAuth.ClientSecret
|
||||
}
|
||||
return &resolvedClientConfig{
|
||||
registrationType: registrationTypePreregistered,
|
||||
clientID: preCfg.ClientID,
|
||||
clientSecret: clientSecret,
|
||||
authStyle: authStyle,
|
||||
}, nil
|
||||
}
|
||||
// 3. Attempt to use dynamic client registration.
|
||||
dcrCfg := h.config.DynamicClientRegistrationConfig
|
||||
if dcrCfg != nil && asm.RegistrationEndpoint != "" {
|
||||
regResp, err := oauthex.RegisterClient(ctx, asm.RegistrationEndpoint, dcrCfg.Metadata, h.config.Client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to register client: %w", err)
|
||||
}
|
||||
cfg := &resolvedClientConfig{
|
||||
registrationType: registrationTypeDynamic,
|
||||
clientID: regResp.ClientID,
|
||||
clientSecret: regResp.ClientSecret,
|
||||
authStyle: authMethodToStyle(regResp.TokenEndpointAuthMethod),
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no configured client registration methods are supported by the authorization server")
|
||||
}
|
||||
|
||||
type authResult struct {
|
||||
*AuthorizationResult
|
||||
// usedCodeVerifier is the PKCE code verifier used to obtain the authorization code.
|
||||
// It is preserved for the token exchange step.
|
||||
usedCodeVerifier string
|
||||
}
|
||||
|
||||
// getAuthorizationCode uses the [AuthorizationCodeHandler.AuthorizationCodeFetcher]
|
||||
// to obtain an authorization code.
|
||||
func (h *AuthorizationCodeHandler) getAuthorizationCode(ctx context.Context, cfg *oauth2.Config, resourceURL string) (*authResult, error) {
|
||||
codeVerifier := oauth2.GenerateVerifier()
|
||||
state := rand.Text()
|
||||
|
||||
authURL := cfg.AuthCodeURL(state,
|
||||
oauth2.S256ChallengeOption(codeVerifier),
|
||||
oauth2.SetAuthURLParam("resource", resourceURL),
|
||||
)
|
||||
|
||||
authRes, err := h.config.AuthorizationCodeFetcher(ctx, &AuthorizationArgs{URL: authURL})
|
||||
if err != nil {
|
||||
// Purposefully leaving the error unwrappable so it can be handled by the caller.
|
||||
return nil, err
|
||||
}
|
||||
if authRes.State != state {
|
||||
return nil, fmt.Errorf("state mismatch")
|
||||
}
|
||||
return &authResult{
|
||||
AuthorizationResult: authRes,
|
||||
usedCodeVerifier: codeVerifier,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateIssuerResponse validates the "iss" parameter in an authorization response
|
||||
// per [RFC 9207].
|
||||
//
|
||||
// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207
|
||||
func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported bool) error {
|
||||
if issParameterSupported {
|
||||
if iss == "" {
|
||||
return fmt.Errorf("authorization server advertises RFC 9207 iss parameter support but none was received in the authorization response")
|
||||
}
|
||||
if iss != expectedIssuer {
|
||||
return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer)
|
||||
}
|
||||
} else {
|
||||
if iss != "" {
|
||||
return fmt.Errorf("authorization server does not advertise RFC 9207 iss parameter support but iss was received in the authorization response")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// exchangeAuthorizationCode exchanges the authorization code for a token
|
||||
// and stores it in a token source.
|
||||
func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context, cfg *oauth2.Config, authResult *authResult, resourceURL string) error {
|
||||
opts := []oauth2.AuthCodeOption{
|
||||
oauth2.VerifierOption(authResult.usedCodeVerifier),
|
||||
oauth2.SetAuthURLParam("resource", resourceURL),
|
||||
}
|
||||
clientCtx := context.WithValue(ctx, oauth2.HTTPClient, h.config.Client)
|
||||
token, err := cfg.Exchange(clientCtx, authResult.Code, opts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("token exchange failed: %w", err)
|
||||
}
|
||||
// The token source outlives this authorization request: it is stored on the
|
||||
// handler and used by the transport for the lifetime of the connection. The
|
||||
// oauth2 library captures the context passed to TokenSource and reuses it for
|
||||
// every subsequent token refresh (see golang.org/x/oauth2: tokenRefresher
|
||||
// retains the context and passes it to each refresh round-trip). Binding it to
|
||||
// the per-request ctx makes all later refreshes fail with "context canceled"
|
||||
// once that request (or the connect operation that triggered authorization)
|
||||
// completes. Use a background context that still carries the configured HTTP
|
||||
// client so refreshes keep working for the life of the token source.
|
||||
refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, h.config.Client)
|
||||
var ts oauth2.TokenSource
|
||||
if h.config.NewTokenSource == nil {
|
||||
ts = cfg.TokenSource(refreshCtx, token)
|
||||
} else {
|
||||
var err error
|
||||
ts, err = h.config.NewTokenSource(refreshCtx, cfg, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("constructing token source failed: %w", err)
|
||||
}
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.tokenSource = ts
|
||||
h.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateGrantedScopes updates the granted scopes based on the token source and requested scopes.
|
||||
func (h *AuthorizationCodeHandler) updateGrantedScopes(issuer string, requestedScopes []string) error {
|
||||
h.mu.RLock()
|
||||
ts := h.tokenSource
|
||||
h.mu.RUnlock()
|
||||
|
||||
if ts == nil {
|
||||
return nil
|
||||
}
|
||||
tok, err := ts.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
if tokenScopes := authutil.ScopesFromToken(tok); tokenScopes == nil {
|
||||
h.grantedScopes[issuer] = requestedScopes
|
||||
} else {
|
||||
h.grantedScopes[issuer] = tokenScopes
|
||||
}
|
||||
h.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// OAuthHandler is an interface for handling OAuth flows.
|
||||
//
|
||||
// If a transport wishes to support OAuth 2 authorization, it should support
|
||||
// being configured with an OAuthHandler. It should call the handler's
|
||||
// TokenSource method whenever it sends an HTTP request to set the
|
||||
// Authorization header. If a request fails with a 401 or 403, it should call
|
||||
// Authorize, and if that returns nil, it should retry the request. It should
|
||||
// not call Authorize after the second failure. See
|
||||
// [github.com/modelcontextprotocol/go-sdk/mcp.StreamableClientTransport]
|
||||
// for an example.
|
||||
type OAuthHandler interface {
|
||||
// TokenSource returns a token source to be used for outgoing requests.
|
||||
// Returned token source might be nil. In that case, the transport will not
|
||||
// add any authorization headers to the request.
|
||||
TokenSource(context.Context) (oauth2.TokenSource, error)
|
||||
|
||||
// Authorize is called when an HTTP request results in an error that may
|
||||
// be addressed by the authorization flow (currently 401 Unauthorized and 403 Forbidden).
|
||||
// It is responsible for performing the OAuth flow to obtain an access token.
|
||||
// The arguments are the request that failed and the response that was received for it.
|
||||
// The headers of the request are available, but the body will have already been consumed
|
||||
// when Authorize is called.
|
||||
// If the returned error is nil, TokenSource is expected to return a non-nil token source.
|
||||
// After a successful call to Authorize, the HTTP request will be retried by the transport.
|
||||
// The function is responsible for closing the response body.
|
||||
Authorize(context.Context, *http.Request, *http.Response) error
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file contains shared utilities for OAuth handlers.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||
)
|
||||
|
||||
// GetAuthServerMetadata fetches authorization server metadata for the given issuer URL.
|
||||
// It tries standard well-known endpoints (OAuth 2.0 and OIDC) and returns the first successful result.
|
||||
//
|
||||
// Returns (nil, nil) when no metadata endpoints respond (404s), allowing callers to implement
|
||||
// fallback logic. Returns an error for any non-client error (network failures, invalid JSON, etc.).
|
||||
func GetAuthServerMetadata(ctx context.Context, issuerURL string, httpClient *http.Client) (*oauthex.AuthServerMeta, error) {
|
||||
for _, metadataURL := range authorizationServerMetadataURLs(issuerURL) {
|
||||
asm, err := oauthex.GetAuthServerMeta(ctx, metadataURL, issuerURL, httpClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if asm != nil {
|
||||
return asm, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// authorizationServerMetadataURLs returns a list of URLs to try when looking for
|
||||
// authorization server metadata as mandated by the MCP specification:
|
||||
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery.
|
||||
func authorizationServerMetadataURLs(issuerURL string) []string {
|
||||
var urls []string
|
||||
|
||||
baseURL, err := url.Parse(issuerURL)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if baseURL.Path == "" {
|
||||
// "OAuth 2.0 Authorization Server Metadata".
|
||||
baseURL.Path = "/.well-known/oauth-authorization-server"
|
||||
urls = append(urls, baseURL.String())
|
||||
// "OpenID Connect Discovery 1.0".
|
||||
baseURL.Path = "/.well-known/openid-configuration"
|
||||
urls = append(urls, baseURL.String())
|
||||
return urls
|
||||
}
|
||||
|
||||
originalPath := baseURL.Path
|
||||
// "OAuth 2.0 Authorization Server Metadata with path insertion".
|
||||
baseURL.Path = "/.well-known/oauth-authorization-server/" + strings.TrimLeft(originalPath, "/")
|
||||
urls = append(urls, baseURL.String())
|
||||
// "OpenID Connect Discovery 1.0 with path insertion".
|
||||
baseURL.Path = "/.well-known/openid-configuration/" + strings.TrimLeft(originalPath, "/")
|
||||
urls = append(urls, baseURL.String())
|
||||
// "OpenID Connect Discovery 1.0 with path appending".
|
||||
baseURL.Path = "/" + strings.Trim(originalPath, "/") + "/.well-known/openid-configuration"
|
||||
urls = append(urls, baseURL.String())
|
||||
|
||||
return urls
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by the license
|
||||
// that can be found in the LICENSE file.
|
||||
package authutil
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// UnionScopes returns the union of the existing and challenged scope sets.
|
||||
// It is used during step-up authorization to accumulate scopes across
|
||||
// authorization rounds (SEP-2350).
|
||||
func UnionScopes(existing, challenged []string) []string {
|
||||
combined := make(map[string]struct{})
|
||||
for _, s := range existing {
|
||||
combined[s] = struct{}{}
|
||||
}
|
||||
for _, s := range challenged {
|
||||
combined[s] = struct{}{}
|
||||
}
|
||||
return slices.Collect(maps.Keys(combined))
|
||||
}
|
||||
|
||||
// ScopesFromToken extracts the granted scopes from an OAuth2 token response.
|
||||
// Per RFC 6749 §5.1, the scope parameter is optional; returns nil if absent.
|
||||
func ScopesFromToken(token *oauth2.Token) []string {
|
||||
scope, ok := token.Extra("scope").(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return strings.Fields(scope)
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by the license
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package authutil
|
||||
|
||||
import "strings"
|
||||
|
||||
// IssuersEqual reports whether two OAuth 2.0 authorization server issuer
|
||||
// identifiers refer to the same server comparing them without the final trailing slash.
|
||||
func IssuersEqual(a, b string) bool {
|
||||
return strings.TrimSuffix(a, "/") == strings.TrimSuffix(b, "/")
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by the license
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
// Package json provides internal JSON utilities.
|
||||
|
||||
package json
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
|
||||
type Decoder struct {
|
||||
dec *json.Decoder
|
||||
}
|
||||
|
||||
func NewDecoder(r io.Reader) *Decoder {
|
||||
dec := json.NewDecoder(r)
|
||||
dec.DontMatchCaseInsensitiveStructFields()
|
||||
return &Decoder{dec: dec}
|
||||
}
|
||||
|
||||
func (d *Decoder) Decode(v any) error {
|
||||
return d.dec.Decode(v)
|
||||
}
|
||||
|
||||
func Unmarshal(data []byte, v any) error {
|
||||
return NewDecoder(bytes.NewReader(data)).Decode(v)
|
||||
}
|
||||
+820
@@ -0,0 +1,820 @@
|
||||
// Copyright 2018 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package jsonrpc2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/json"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug"
|
||||
)
|
||||
|
||||
// nomethodnotfoundcodeinerror is a compatibility parameter that restores the
|
||||
// pre-fix behavior of [processResult], where wrapped [ErrNotHandled] or
|
||||
// [ErrMethodNotFound] errors returned by request handlers were not
|
||||
// recognized as "method not found" signals. The original switch statement
|
||||
// compared sentinel errors with ==, which never matched errors returned via
|
||||
// fmt.Errorf("%w: ...", ErrNotHandled, ...) — including the ones produced
|
||||
// by checkRequest. As a result the wire error response carried code 0
|
||||
// instead of code -32601. The fix uses errors.Is to recognize wrapped
|
||||
// sentinels and append the method name to the message.
|
||||
//
|
||||
// To restore the previous behavior, set MCPGODEBUG=nomethodnotfoundcodeinerror=1.
|
||||
// This option will be removed in a future SDK version.
|
||||
// See the documentation for the mcpgodebug package for instructions on how
|
||||
// to use it.
|
||||
var nomethodnotfoundcodeinerror = mcpgodebug.Value("nomethodnotfoundcodeinerror")
|
||||
|
||||
// Connection manages the jsonrpc2 protocol, connecting responses back to their
|
||||
// calls. Connection is bidirectional; it does not have a designated server or
|
||||
// client end.
|
||||
//
|
||||
// Note that the word 'Connection' is overloaded: the mcp.Connection represents
|
||||
// the bidirectional stream of messages between client an server. The
|
||||
// jsonrpc2.Connection layers RPC logic on top of that stream, dispatching RPC
|
||||
// handlers, and correlating requests with responses from the peer.
|
||||
//
|
||||
// Some of the complexity of the Connection type is grown out of its usage in
|
||||
// gopls: it could probably be simplified based on our usage in MCP.
|
||||
type Connection struct {
|
||||
seq int64 // must only be accessed using atomic operations
|
||||
|
||||
stateMu sync.Mutex
|
||||
state inFlightState // accessed only in updateInFlight
|
||||
done chan struct{} // closed (under stateMu) when state.closed is true and all goroutines have completed
|
||||
|
||||
writer Writer
|
||||
handler Handler
|
||||
|
||||
onInternalError func(error)
|
||||
onDone func()
|
||||
}
|
||||
|
||||
// inFlightState records the state of the incoming and outgoing calls on a
|
||||
// Connection.
|
||||
type inFlightState struct {
|
||||
connClosing bool // true when the Connection's Close method has been called
|
||||
reading bool // true while the readIncoming goroutine is running
|
||||
readErr error // non-nil when the readIncoming goroutine exits (typically io.EOF)
|
||||
writeErr error // non-nil if a call to the Writer has failed with a non-canceled Context
|
||||
|
||||
// closer shuts down and cleans up the Reader and Writer state, ideally
|
||||
// interrupting any Read or Write call that is currently blocked. It is closed
|
||||
// when the state is idle and one of: connClosing is true, readErr is non-nil,
|
||||
// or writeErr is non-nil.
|
||||
//
|
||||
// After the closer has been invoked, the closer field is set to nil
|
||||
// and the closeErr field is simultaneously set to its result.
|
||||
closer io.Closer
|
||||
closeErr error // error returned from closer.Close
|
||||
|
||||
outgoingCalls map[ID]*AsyncCall // calls only
|
||||
outgoingNotifications int // # of notifications awaiting "write"
|
||||
|
||||
// incoming stores the total number of incoming calls and notifications
|
||||
// that have not yet written or processed a result.
|
||||
incoming int
|
||||
|
||||
incomingByID map[ID]*incomingRequest // calls only
|
||||
|
||||
// handlerQueue stores the backlog of calls and notifications that were not
|
||||
// already handled by a preempter.
|
||||
// The queue does not include the request currently being handled (if any).
|
||||
handlerQueue []*incomingRequest
|
||||
handlerRunning bool
|
||||
}
|
||||
|
||||
// updateInFlight locks the state of the connection's in-flight requests, allows
|
||||
// f to mutate that state, and closes the connection if it is idle and either
|
||||
// is closing or has a read or write error.
|
||||
func (c *Connection) updateInFlight(f func(*inFlightState)) {
|
||||
c.stateMu.Lock()
|
||||
defer c.stateMu.Unlock()
|
||||
|
||||
s := &c.state
|
||||
|
||||
f(s)
|
||||
|
||||
select {
|
||||
case <-c.done:
|
||||
// The connection was already completely done at the start of this call to
|
||||
// updateInFlight, so it must remain so. (The call to f should have noticed
|
||||
// that and avoided making any updates that would cause the state to be
|
||||
// non-idle.)
|
||||
if !s.idle() {
|
||||
panic("jsonrpc2: updateInFlight transitioned to non-idle when already done")
|
||||
}
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if s.idle() && s.shuttingDown(ErrUnknown) != nil {
|
||||
if s.closer != nil {
|
||||
s.closeErr = s.closer.Close()
|
||||
s.closer = nil // prevent duplicate Close calls
|
||||
}
|
||||
if s.reading {
|
||||
// The readIncoming goroutine is still running. Our call to Close should
|
||||
// cause it to exit soon, at which point it will make another call to
|
||||
// updateInFlight, set s.reading to false, and mark the Connection done.
|
||||
} else {
|
||||
// The readIncoming goroutine has exited, or never started to begin with.
|
||||
// Since everything else is idle, we're completely done.
|
||||
if c.onDone != nil {
|
||||
c.onDone()
|
||||
}
|
||||
close(c.done)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// idle reports whether the connection is in a state with no pending calls or
|
||||
// notifications.
|
||||
//
|
||||
// If idle returns true, the readIncoming goroutine may still be running,
|
||||
// but no other goroutines are doing work on behalf of the connection.
|
||||
func (s *inFlightState) idle() bool {
|
||||
return len(s.outgoingCalls) == 0 && s.outgoingNotifications == 0 && s.incoming == 0 && !s.handlerRunning
|
||||
}
|
||||
|
||||
// shuttingDown reports whether the connection is in a state that should
|
||||
// disallow new (incoming and outgoing) calls. It returns either nil or
|
||||
// an error that is or wraps the provided errClosing.
|
||||
func (s *inFlightState) shuttingDown(errClosing error) error {
|
||||
if s.connClosing {
|
||||
// If Close has been called explicitly, it doesn't matter what state the
|
||||
// Reader and Writer are in: we shouldn't be starting new work because the
|
||||
// caller told us not to start new work.
|
||||
return errClosing
|
||||
}
|
||||
if s.readErr != nil {
|
||||
// If the read side of the connection is broken, we cannot read new call
|
||||
// requests, and cannot read responses to our outgoing calls.
|
||||
return fmt.Errorf("%w: %v", errClosing, s.readErr)
|
||||
}
|
||||
if s.writeErr != nil {
|
||||
// If the write side of the connection is broken, we cannot write responses
|
||||
// for incoming calls, and cannot write requests for outgoing calls.
|
||||
return fmt.Errorf("%w: %v", errClosing, s.writeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// incomingRequest is used to track an incoming request as it is being handled
|
||||
type incomingRequest struct {
|
||||
*Request // the request being processed
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// Reader abstracts the transport mechanics from the JSON RPC protocol.
|
||||
// A Connection reads messages from the reader it was provided on construction,
|
||||
// and assumes that each call to Read fully transfers a single message,
|
||||
// or returns an error.
|
||||
//
|
||||
// A reader is not safe for concurrent use, it is expected it will be used by
|
||||
// a single Connection in a safe manner.
|
||||
type Reader interface {
|
||||
// Read gets the next message from the stream.
|
||||
Read(context.Context) (Message, error)
|
||||
}
|
||||
|
||||
// Writer abstracts the transport mechanics from the JSON RPC protocol.
|
||||
// A Connection writes messages using the writer it was provided on construction,
|
||||
// and assumes that each call to Write fully transfers a single message,
|
||||
// or returns an error.
|
||||
//
|
||||
// A writer must be safe for concurrent use, as writes may occur concurrently
|
||||
// in practice: libraries may make calls or respond to requests asynchronously.
|
||||
type Writer interface {
|
||||
// Write sends a message to the stream.
|
||||
Write(context.Context, Message) error
|
||||
}
|
||||
|
||||
// A ConnectionConfig configures a bidirectional jsonrpc2 connection.
|
||||
type ConnectionConfig struct {
|
||||
Reader Reader // required
|
||||
Writer Writer // required
|
||||
Closer io.Closer // required
|
||||
Preempter Preempter // optional
|
||||
Bind func(*Connection) Handler // required
|
||||
OnDone func() // optional
|
||||
OnInternalError func(error) // optional
|
||||
|
||||
// PropagateCancellation controls whether cancellation of the context
|
||||
// passed to [NewConnection] is observable by request handlers.
|
||||
//
|
||||
// By default (false), the connection wraps that context (see [notDone])
|
||||
// so handlers' Done channels do not fire when the connection's root
|
||||
// context is cancelled. Cancellation of an in-flight handler is then
|
||||
// expected to flow only through the jsonrpc2 layer's explicit channels
|
||||
// (the [Preempter] reacting to the peer's cancel notification, or a
|
||||
// transport read/write failure cancelling every in-flight request).
|
||||
//
|
||||
// Set this true when cancellation of the connection's context is itself
|
||||
// a meaningful signal that handlers should react to (for example, when
|
||||
// the connection is tied to a carrier that owns it and whose end means
|
||||
// the request is cancelled).
|
||||
PropagateCancellation bool // optional
|
||||
}
|
||||
|
||||
// NewConnection creates a new [Connection] object and starts processing
|
||||
// incoming messages.
|
||||
func NewConnection(ctx context.Context, cfg ConnectionConfig) *Connection {
|
||||
if !cfg.PropagateCancellation {
|
||||
ctx = notDone{ctx}
|
||||
}
|
||||
|
||||
c := &Connection{
|
||||
state: inFlightState{closer: cfg.Closer},
|
||||
done: make(chan struct{}),
|
||||
writer: cfg.Writer,
|
||||
onDone: cfg.OnDone,
|
||||
onInternalError: cfg.OnInternalError,
|
||||
}
|
||||
c.handler = cfg.Bind(c)
|
||||
c.start(ctx, cfg.Reader, cfg.Preempter)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Connection) start(ctx context.Context, reader Reader, preempter Preempter) {
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
select {
|
||||
case <-c.done:
|
||||
// The connection was already closed; don't start a goroutine to read it.
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// The goroutine started here will continue until the underlying stream is closed.
|
||||
s.reading = true
|
||||
go c.readIncoming(ctx, reader, preempter)
|
||||
})
|
||||
}
|
||||
|
||||
// Notify invokes the target method but does not wait for a response.
|
||||
// The params will be marshaled to JSON before sending over the wire, and will
|
||||
// be handed to the method invoked.
|
||||
func (c *Connection) Notify(ctx context.Context, method string, params any) (err error) {
|
||||
attempted := false
|
||||
|
||||
defer func() {
|
||||
if attempted {
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
s.outgoingNotifications--
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
// If the connection is shutting down, allow outgoing notifications only if
|
||||
// there is at least one call still in flight. The number of calls in flight
|
||||
// cannot increase once shutdown begins, and allowing outgoing notifications
|
||||
// may permit notifications that will cancel in-flight calls.
|
||||
if len(s.outgoingCalls) == 0 && len(s.incomingByID) == 0 {
|
||||
err = s.shuttingDown(ErrClientClosing)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
s.outgoingNotifications++
|
||||
attempted = true
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notify, err := NewNotification(method, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling notify parameters: %v", err)
|
||||
}
|
||||
|
||||
return c.write(ctx, notify)
|
||||
}
|
||||
|
||||
// Call invokes the target method and returns an object that can be used to await the response.
|
||||
// The params will be marshaled to JSON before sending over the wire, and will
|
||||
// be handed to the method invoked.
|
||||
// You do not have to wait for the response, it can just be ignored if not needed.
|
||||
// If sending the call failed, the response will be ready and have the error in it.
|
||||
func (c *Connection) Call(ctx context.Context, method string, params any) *AsyncCall {
|
||||
// Generate a new request identifier.
|
||||
id := Int64ID(atomic.AddInt64(&c.seq, 1))
|
||||
|
||||
ac := &AsyncCall{
|
||||
id: id,
|
||||
ready: make(chan struct{}),
|
||||
}
|
||||
// When this method returns, either ac is retired, or the request has been
|
||||
// written successfully and the call is awaiting a response (to be provided by
|
||||
// the readIncoming goroutine).
|
||||
|
||||
call, err := NewCall(ac.id, method, params)
|
||||
if err != nil {
|
||||
ac.retire(&Response{ID: id, Error: fmt.Errorf("marshaling call parameters: %w", err)})
|
||||
return ac
|
||||
}
|
||||
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
err = s.shuttingDown(ErrClientClosing)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if s.outgoingCalls == nil {
|
||||
s.outgoingCalls = make(map[ID]*AsyncCall)
|
||||
}
|
||||
s.outgoingCalls[ac.id] = ac
|
||||
})
|
||||
if err != nil {
|
||||
ac.retire(&Response{ID: id, Error: err})
|
||||
return ac
|
||||
}
|
||||
|
||||
if err := c.write(ctx, call); err != nil {
|
||||
// Sending failed. We will never get a response, so deliver a fake one if it
|
||||
// wasn't already retired by the connection breaking.
|
||||
c.Retire(ac, err)
|
||||
}
|
||||
return ac
|
||||
}
|
||||
|
||||
// Retire stops tracking the call, and reports err as its terminal error.
|
||||
//
|
||||
// Retire is safe to call multiple times: if the call is already no longer
|
||||
// tracked, Retire is a no op.
|
||||
func (c *Connection) Retire(ac *AsyncCall, err error) {
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
if s.outgoingCalls[ac.id] == ac {
|
||||
delete(s.outgoingCalls, ac.id)
|
||||
ac.retire(&Response{ID: ac.id, Error: err})
|
||||
} else {
|
||||
// ac was already retired elsewhere.
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Async, signals that the current jsonrpc2 request may be handled
|
||||
// asynchronously to subsequent requests, when ctx is the request context.
|
||||
//
|
||||
// Async must be called at most once on each request's context (and its
|
||||
// descendants).
|
||||
func Async(ctx context.Context) {
|
||||
if r, ok := ctx.Value(asyncKey).(*releaser); ok {
|
||||
r.release(false)
|
||||
}
|
||||
}
|
||||
|
||||
type asyncKeyType struct{}
|
||||
|
||||
var asyncKey = asyncKeyType{}
|
||||
|
||||
// A releaser implements concurrency safe 'releasing' of async requests. (A
|
||||
// request is released when it is allowed to run concurrent with other
|
||||
// requests, via a call to [Async].)
|
||||
type releaser struct {
|
||||
mu sync.Mutex
|
||||
ch chan struct{}
|
||||
released bool
|
||||
}
|
||||
|
||||
// release closes the associated channel. If soft is set, multiple calls to
|
||||
// release are allowed.
|
||||
func (r *releaser) release(soft bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.released {
|
||||
if !soft {
|
||||
panic("jsonrpc2.Async called multiple times")
|
||||
}
|
||||
} else {
|
||||
close(r.ch)
|
||||
r.released = true
|
||||
}
|
||||
}
|
||||
|
||||
type AsyncCall struct {
|
||||
id ID
|
||||
ready chan struct{} // closed after response has been set
|
||||
response *Response
|
||||
}
|
||||
|
||||
// ID used for this call.
|
||||
// This can be used to cancel the call if needed.
|
||||
func (ac *AsyncCall) ID() ID { return ac.id }
|
||||
|
||||
// retire processes the response to the call.
|
||||
//
|
||||
// It is an error to call retire more than once: retire is guarded by the
|
||||
// connection's outgoingCalls map.
|
||||
func (ac *AsyncCall) retire(response *Response) {
|
||||
select {
|
||||
case <-ac.ready:
|
||||
panic(fmt.Sprintf("jsonrpc2: retire called twice for ID %v", ac.id))
|
||||
default:
|
||||
}
|
||||
|
||||
ac.response = response
|
||||
close(ac.ready)
|
||||
}
|
||||
|
||||
// Await waits for (and decodes) the results of a Call.
|
||||
// The response will be unmarshaled from JSON into the result.
|
||||
//
|
||||
// If the call is cancelled due to context cancellation, the result is
|
||||
// ctx.Err().
|
||||
func (ac *AsyncCall) Await(ctx context.Context, result any) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ac.ready:
|
||||
}
|
||||
if ac.response.Error != nil {
|
||||
return ac.response.Error
|
||||
}
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(ac.response.Result, result)
|
||||
}
|
||||
|
||||
// Cancel cancels the Context passed to the Handle call for the inbound message
|
||||
// with the given ID.
|
||||
//
|
||||
// Cancel will not complain if the ID is not a currently active message, and it
|
||||
// will not cause any messages that have not arrived yet with that ID to be
|
||||
// cancelled.
|
||||
func (c *Connection) Cancel(id ID) {
|
||||
var req *incomingRequest
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
req = s.incomingByID[id]
|
||||
})
|
||||
if req != nil {
|
||||
req.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Wait blocks until the connection is fully closed, but does not close it.
|
||||
func (c *Connection) Wait() error {
|
||||
return c.wait(true)
|
||||
}
|
||||
|
||||
// wait for the connection to close, and aggregates the most cause of its
|
||||
// termination, if abnormal.
|
||||
//
|
||||
// The fromWait argument allows this logic to be shared with Close, where we
|
||||
// only want to expose the closeErr.
|
||||
//
|
||||
// (Previously, Wait also only returned the closeErr, which was misleading if
|
||||
// the connection was broken for another reason).
|
||||
func (c *Connection) wait(fromWait bool) error {
|
||||
var err error
|
||||
<-c.done
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
if fromWait {
|
||||
if !errors.Is(s.readErr, io.EOF) {
|
||||
err = s.readErr
|
||||
}
|
||||
if err == nil && !errors.Is(s.writeErr, io.EOF) {
|
||||
err = s.writeErr
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = s.closeErr
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Close stops accepting new requests, waits for in-flight requests and enqueued
|
||||
// Handle calls to complete, and then closes the underlying stream.
|
||||
//
|
||||
// After the start of a Close, notification requests (that lack IDs and do not
|
||||
// receive responses) will continue to be passed to the Preempter, but calls
|
||||
// with IDs will receive immediate responses with ErrServerClosing, and no new
|
||||
// requests (not even notifications!) will be enqueued to the Handler.
|
||||
func (c *Connection) Close() error {
|
||||
// Stop handling new requests, and interrupt the reader (by closing the
|
||||
// connection) as soon as the active requests finish.
|
||||
c.updateInFlight(func(s *inFlightState) { s.connClosing = true })
|
||||
return c.wait(false)
|
||||
}
|
||||
|
||||
// readIncoming collects inbound messages from the reader and delivers them, either responding
|
||||
// to outgoing calls or feeding requests to the queue.
|
||||
func (c *Connection) readIncoming(ctx context.Context, reader Reader, preempter Preempter) {
|
||||
var err error
|
||||
for {
|
||||
var msg Message
|
||||
msg, err = reader.Read(ctx)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case *Request:
|
||||
c.acceptRequest(ctx, msg, preempter)
|
||||
|
||||
case *Response:
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
if ac, ok := s.outgoingCalls[msg.ID]; ok {
|
||||
delete(s.outgoingCalls, msg.ID)
|
||||
ac.retire(msg)
|
||||
} else {
|
||||
// TODO: How should we report unexpected responses?
|
||||
}
|
||||
})
|
||||
|
||||
default:
|
||||
c.internalErrorf("Read returned an unexpected message of type %T", msg)
|
||||
}
|
||||
}
|
||||
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
s.reading = false
|
||||
s.readErr = err
|
||||
|
||||
// Retire any outgoing requests that were still in flight: with the Reader no
|
||||
// longer being processed, they necessarily cannot receive a response.
|
||||
for id, ac := range s.outgoingCalls {
|
||||
ac.retire(&Response{ID: id, Error: err})
|
||||
}
|
||||
s.outgoingCalls = nil
|
||||
|
||||
// Cancel any incoming requests still in flight: with the reader gone we
|
||||
// cannot receive cancellation notifications, and likely cannot write a
|
||||
// response either, so parked handlers have nothing useful left to do.
|
||||
// Mirrors the equivalent cleanup on write failure.
|
||||
for _, r := range s.incomingByID {
|
||||
r.cancel()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// acceptRequest either handles msg synchronously or enqueues it to be handled
|
||||
// asynchronously.
|
||||
func (c *Connection) acceptRequest(ctx context.Context, msg *Request, preempter Preempter) {
|
||||
// In theory notifications cannot be cancelled, but we build them a cancel
|
||||
// context anyway.
|
||||
reqCtx, cancel := context.WithCancel(ctx)
|
||||
req := &incomingRequest{
|
||||
Request: msg,
|
||||
ctx: reqCtx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
// If the request is a call, add it to the incoming map so it can be
|
||||
// cancelled (or responded) by ID.
|
||||
var err error
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
s.incoming++
|
||||
|
||||
if req.IsCall() {
|
||||
if s.incomingByID[req.ID] != nil {
|
||||
err = fmt.Errorf("%w: request ID %v already in use", ErrInvalidRequest, req.ID)
|
||||
req.ID = ID{} // Don't misattribute this error to the existing request.
|
||||
return
|
||||
}
|
||||
|
||||
if s.incomingByID == nil {
|
||||
s.incomingByID = make(map[ID]*incomingRequest)
|
||||
}
|
||||
s.incomingByID[req.ID] = req
|
||||
|
||||
// When shutting down, reject all new Call requests, even if they could
|
||||
// theoretically be handled by the preempter. The preempter could return
|
||||
// ErrAsyncResponse, which would increase the amount of work in flight
|
||||
// when we're trying to ensure that it strictly decreases.
|
||||
err = s.shuttingDown(ErrServerClosing)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
c.processResult("acceptRequest", req, nil, err)
|
||||
return
|
||||
}
|
||||
|
||||
if preempter != nil {
|
||||
result, err := preempter.Preempt(req.ctx, req.Request)
|
||||
|
||||
if !errors.Is(err, ErrNotHandled) {
|
||||
c.processResult("Preempt", req, result, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
// If the connection is shutting down, don't enqueue anything to the
|
||||
// handler — not even notifications. That ensures that if the handler
|
||||
// continues to make progress, it will eventually become idle and
|
||||
// close the connection.
|
||||
err = s.shuttingDown(ErrServerClosing)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// We enqueue requests that have not been preempted to an unbounded slice.
|
||||
// Unfortunately, we cannot in general limit the size of the handler
|
||||
// queue: we have to read every response that comes in on the wire
|
||||
// (because it may be responding to a request issued by, say, an
|
||||
// asynchronous handler), and in order to get to that response we have
|
||||
// to read all of the requests that came in ahead of it.
|
||||
s.handlerQueue = append(s.handlerQueue, req)
|
||||
if !s.handlerRunning {
|
||||
// We start the handleAsync goroutine when it has work to do, and let it
|
||||
// exit when the queue empties.
|
||||
//
|
||||
// Otherwise, in order to synchronize the handler we would need some other
|
||||
// goroutine (probably readIncoming?) to explicitly wait for handleAsync
|
||||
// to finish, and that would complicate error reporting: either the error
|
||||
// report from the goroutine would be blocked on the handler emptying its
|
||||
// queue (which was tried, and introduced a deadlock detected by
|
||||
// TestCloseCallRace), or the error would need to be reported separately
|
||||
// from synchronizing completion. Allowing the handler goroutine to exit
|
||||
// when idle seems simpler than trying to implement either of those
|
||||
// alternatives correctly.
|
||||
s.handlerRunning = true
|
||||
go c.handleAsync()
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
c.processResult("acceptRequest", req, nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAsync invokes the handler on the requests in the handler queue
|
||||
// sequentially until the queue is empty.
|
||||
func (c *Connection) handleAsync() {
|
||||
for {
|
||||
var req *incomingRequest
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
if len(s.handlerQueue) > 0 {
|
||||
req, s.handlerQueue = s.handlerQueue[0], s.handlerQueue[1:]
|
||||
} else {
|
||||
s.handlerRunning = false
|
||||
}
|
||||
})
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Only deliver to the Handler if not already canceled.
|
||||
if err := req.ctx.Err(); err != nil {
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
if s.writeErr != nil {
|
||||
// Assume that req.ctx was canceled due to s.writeErr.
|
||||
// TODO(#51365): use a Context API to plumb this through req.ctx.
|
||||
err = fmt.Errorf("%w: %v", ErrServerClosing, s.writeErr)
|
||||
}
|
||||
})
|
||||
c.processResult("handleAsync", req, nil, err)
|
||||
continue
|
||||
}
|
||||
|
||||
releaser := &releaser{ch: make(chan struct{})}
|
||||
ctx := context.WithValue(req.ctx, asyncKey, releaser)
|
||||
go func() {
|
||||
defer releaser.release(true)
|
||||
result, err := c.handler.Handle(ctx, req.Request)
|
||||
c.processResult(c.handler, req, result, err)
|
||||
}()
|
||||
<-releaser.ch
|
||||
}
|
||||
}
|
||||
|
||||
// processResult processes the result of a request and, if appropriate, sends a response.
|
||||
func (c *Connection) processResult(from any, req *incomingRequest, result any, err error) error {
|
||||
if nomethodnotfoundcodeinerror != "1" && (errors.Is(err, ErrNotHandled) || errors.Is(err, ErrMethodNotFound)) {
|
||||
err = fmt.Errorf("%w: %q", ErrMethodNotFound, req.Method)
|
||||
}
|
||||
|
||||
if result != nil && err != nil {
|
||||
c.internalErrorf("%#v returned a non-nil result with a non-nil error for %s:\n%v\n%#v", from, req.Method, err, result)
|
||||
result = nil // Discard the spurious result and respond with err.
|
||||
}
|
||||
|
||||
if req.IsCall() {
|
||||
if result == nil && err == nil {
|
||||
err = c.internalErrorf("%#v returned a nil result and nil error for a %q Request that requires a Response", from, req.Method)
|
||||
}
|
||||
|
||||
response, respErr := NewResponse(req.ID, result, err)
|
||||
|
||||
// The caller could theoretically reuse the request's ID as soon as we've
|
||||
// sent the response, so ensure that it is removed from the incoming map
|
||||
// before sending.
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
delete(s.incomingByID, req.ID)
|
||||
})
|
||||
if respErr == nil {
|
||||
writeErr := c.write(notDone{req.ctx}, response)
|
||||
if err == nil {
|
||||
err = writeErr
|
||||
}
|
||||
} else {
|
||||
err = c.internalErrorf("%#v returned a malformed result for %q: %w", from, req.Method, respErr)
|
||||
}
|
||||
} else { // req is a notification
|
||||
if result != nil {
|
||||
err = c.internalErrorf("%#v returned a non-nil result for a %q Request without an ID", from, req.Method)
|
||||
} else if err != nil {
|
||||
err = fmt.Errorf("%w: %q notification failed: %v", ErrInternal, req.Method, err)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// TODO: can/should we do anything with this error beyond writing it to the event log?
|
||||
// (Is this the right label to attach to the log?)
|
||||
}
|
||||
|
||||
// Cancel the request to free any associated resources.
|
||||
req.cancel()
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
if s.incoming == 0 {
|
||||
panic("jsonrpc2: processResult called when incoming count is already zero")
|
||||
}
|
||||
s.incoming--
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// write is used by all things that write outgoing messages, including replies.
|
||||
// it makes sure that writes are atomic
|
||||
func (c *Connection) write(ctx context.Context, msg Message) error {
|
||||
var err error
|
||||
// Fail writes immediately if the connection is shutting down.
|
||||
//
|
||||
// Allow outgoing "notifications" forwarded by the Notify method.
|
||||
// This will allow to send the cancelled notification when the client is shutting down.
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
if req, ok := msg.(*Request); ok && !req.IsCall() && s.outgoingNotifications > 0 {
|
||||
return
|
||||
}
|
||||
err = s.shuttingDown(ErrServerClosing)
|
||||
})
|
||||
if err == nil {
|
||||
err = c.writer.Write(ctx, msg)
|
||||
}
|
||||
|
||||
// For cancelled or rejected requests, we don't set the writeErr (which would
|
||||
// break the connection). They can just be returned to the caller.
|
||||
if err != nil && ctx.Err() == nil && !errors.Is(err, ErrRejected) {
|
||||
// The call to Write failed, and since ctx.Err() is nil we can't attribute
|
||||
// the failure (even indirectly) to Context cancellation. The writer appears
|
||||
// to be broken, and future writes are likely to also fail.
|
||||
//
|
||||
// If the read side of the connection is also broken, we might not even be
|
||||
// able to receive cancellation notifications. Since we can't reliably write
|
||||
// the results of incoming calls and can't receive explicit cancellations,
|
||||
// cancel the calls now.
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
if s.writeErr == nil {
|
||||
s.writeErr = err
|
||||
for _, r := range s.incomingByID {
|
||||
r.cancel()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// internalErrorf reports an internal error. By default it panics, but if
|
||||
// c.onInternalError is non-nil it instead calls that and returns an error
|
||||
// wrapping ErrInternal.
|
||||
func (c *Connection) internalErrorf(format string, args ...any) error {
|
||||
err := fmt.Errorf(format, args...)
|
||||
if c.onInternalError == nil {
|
||||
panic("jsonrpc2: " + err.Error())
|
||||
}
|
||||
c.onInternalError(err)
|
||||
|
||||
return fmt.Errorf("%w: %v", ErrInternal, err)
|
||||
}
|
||||
|
||||
// notDone is a context.Context wrapper that returns a nil Done channel.
|
||||
//
|
||||
// Request handlers' contexts are derived from the connection's root context,
|
||||
// which by default is wrapped in notDone so a transport-level cancellation
|
||||
// does not implicitly cancel every in-flight handler. Cancellation of an
|
||||
// in-flight handler is instead expected to flow only through the jsonrpc2
|
||||
// layer's explicit channels: the [Preempter] calling [Connection.Cancel] in
|
||||
// response to the peer's cancel notification, or the transport itself
|
||||
// failing (the read loop exits on EOF or a write fails) — both of which
|
||||
// cancel every in-flight incoming request in turn.
|
||||
type notDone struct{ ctx context.Context }
|
||||
|
||||
func (ic notDone) Value(key any) any {
|
||||
return ic.ctx.Value(key)
|
||||
}
|
||||
|
||||
func (notDone) Done() <-chan struct{} { return nil }
|
||||
func (notDone) Err() error { return nil }
|
||||
func (notDone) Deadline() (time.Time, bool) { return time.Time{}, false }
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
// Copyright 2018 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package jsonrpc2 is a minimal implementation of the JSON RPC 2 spec.
|
||||
// https://www.jsonrpc.org/specification
|
||||
// It is intended to be compatible with other implementations at the wire level.
|
||||
package jsonrpc2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrNotHandled is returned from a Handler or Preempter to indicate it did
|
||||
// not handle the request.
|
||||
//
|
||||
// If a Handler returns ErrNotHandled, the server replies with
|
||||
// ErrMethodNotFound.
|
||||
var ErrNotHandled = errors.New("JSON RPC not handled")
|
||||
|
||||
// Preempter handles messages on a connection before they are queued to the main
|
||||
// handler.
|
||||
// Primarily this is used for cancel handlers or notifications for which out of
|
||||
// order processing is not an issue.
|
||||
type Preempter interface {
|
||||
// Preempt is invoked for each incoming request before it is queued for handling.
|
||||
//
|
||||
// If Preempt returns ErrNotHandled, the request will be queued,
|
||||
// and eventually passed to a Handle call.
|
||||
//
|
||||
// Otherwise, the result and error are processed as if returned by Handle.
|
||||
//
|
||||
// Preempt must not block. (The Context passed to it is for Values only.)
|
||||
Preempt(ctx context.Context, req *Request) (result any, err error)
|
||||
}
|
||||
|
||||
// Handler handles messages on a connection.
|
||||
type Handler interface {
|
||||
// Handle is invoked sequentially for each incoming request that has not
|
||||
// already been handled by a Preempter.
|
||||
//
|
||||
// If the Request has a nil ID, Handle must return a nil result,
|
||||
// and any error may be logged but will not be reported to the caller.
|
||||
//
|
||||
// If the Request has a non-nil ID, Handle must return either a
|
||||
// non-nil, JSON-marshalable result, or a non-nil error.
|
||||
//
|
||||
// The Context passed to Handle will be canceled if the
|
||||
// connection is broken or the request is canceled or completed.
|
||||
// (If Handle returns ErrAsyncResponse, ctx will remain uncanceled
|
||||
// until either Cancel or Respond is called for the request's ID.)
|
||||
Handle(ctx context.Context, req *Request) (result any, err error)
|
||||
}
|
||||
|
||||
// A HandlerFunc implements the Handler interface for a standalone Handle function.
|
||||
type HandlerFunc func(ctx context.Context, req *Request) (any, error)
|
||||
|
||||
func (f HandlerFunc) Handle(ctx context.Context, req *Request) (any, error) {
|
||||
return f(ctx, req)
|
||||
}
|
||||
|
||||
var _ Handler = HandlerFunc(nil)
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
// Copyright 2018 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package jsonrpc2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
internaljson "github.com/modelcontextprotocol/go-sdk/internal/json"
|
||||
)
|
||||
|
||||
// ID is a Request identifier, which is defined by the spec to be a string, integer, or null.
|
||||
// https://www.jsonrpc.org/specification#request_object
|
||||
type ID struct {
|
||||
value any
|
||||
}
|
||||
|
||||
// MakeID coerces the given Go value to an ID. The value should be the
|
||||
// default JSON marshaling of a Request identifier: nil, float64, or string.
|
||||
//
|
||||
// Returns an error if the value type was not a valid Request ID type.
|
||||
//
|
||||
// TODO: ID can't be a json.Marshaler/Unmarshaler, because we want to omitzero.
|
||||
// Simplify this package by making ID json serializable once we can rely on
|
||||
// omitzero.
|
||||
func MakeID(v any) (ID, error) {
|
||||
switch v := v.(type) {
|
||||
case nil:
|
||||
return ID{}, nil
|
||||
case float64:
|
||||
return Int64ID(int64(v)), nil
|
||||
case string:
|
||||
return StringID(v), nil
|
||||
}
|
||||
return ID{}, fmt.Errorf("%w: invalid ID type %T", ErrParse, v)
|
||||
}
|
||||
|
||||
// Message is the interface to all jsonrpc2 message types.
|
||||
// They share no common functionality, but are a closed set of concrete types
|
||||
// that are allowed to implement this interface. The message types are *Request
|
||||
// and *Response.
|
||||
type Message interface {
|
||||
// marshal builds the wire form from the API form.
|
||||
// It is private, which makes the set of Message implementations closed.
|
||||
marshal(to *wireCombined)
|
||||
}
|
||||
|
||||
// Request is a Message sent to a peer to request behavior.
|
||||
// If it has an ID it is a call, otherwise it is a notification.
|
||||
type Request struct {
|
||||
// ID of this request, used to tie the Response back to the request.
|
||||
// This will be nil for notifications.
|
||||
ID ID
|
||||
// Method is a string containing the method name to invoke.
|
||||
Method string
|
||||
// Params is either a struct or an array with the parameters of the method.
|
||||
Params json.RawMessage
|
||||
// Extra is additional information that does not appear on the wire. It can be
|
||||
// used to pass information from the application to the underlying transport.
|
||||
Extra any
|
||||
}
|
||||
|
||||
// Response is a Message used as a reply to a call Request.
|
||||
// It will have the same ID as the call it is a response to.
|
||||
type Response struct {
|
||||
// result is the content of the response.
|
||||
Result json.RawMessage
|
||||
// err is set only if the call failed.
|
||||
Error error
|
||||
// id of the request this is a response to.
|
||||
ID ID
|
||||
// Extra is additional information that does not appear on the wire. It can be
|
||||
// used to pass information from the underlying transport to the application.
|
||||
Extra any
|
||||
}
|
||||
|
||||
// StringID creates a new string request identifier.
|
||||
func StringID(s string) ID { return ID{value: s} }
|
||||
|
||||
// Int64ID creates a new integer request identifier.
|
||||
func Int64ID(i int64) ID { return ID{value: i} }
|
||||
|
||||
// IsValid returns true if the ID is a valid identifier.
|
||||
// The default value for ID will return false.
|
||||
func (id ID) IsValid() bool { return id.value != nil }
|
||||
|
||||
// Raw returns the underlying value of the ID.
|
||||
func (id ID) Raw() any { return id.value }
|
||||
|
||||
// NewNotification constructs a new Notification message for the supplied
|
||||
// method and parameters.
|
||||
func NewNotification(method string, params any) (*Request, error) {
|
||||
p, merr := marshalToRaw(params)
|
||||
return &Request{Method: method, Params: p}, merr
|
||||
}
|
||||
|
||||
// NewCall constructs a new Call message for the supplied ID, method and
|
||||
// parameters.
|
||||
func NewCall(id ID, method string, params any) (*Request, error) {
|
||||
p, merr := marshalToRaw(params)
|
||||
return &Request{ID: id, Method: method, Params: p}, merr
|
||||
}
|
||||
|
||||
func (msg *Request) IsCall() bool { return msg.ID.IsValid() }
|
||||
|
||||
func (msg *Request) marshal(to *wireCombined) {
|
||||
to.ID = msg.ID.value
|
||||
to.Method = msg.Method
|
||||
to.Params = msg.Params
|
||||
}
|
||||
|
||||
// NewResponse constructs a new Response message that is a reply to the
|
||||
// supplied. If err is set result may be ignored.
|
||||
func NewResponse(id ID, result any, rerr error) (*Response, error) {
|
||||
r, merr := marshalToRaw(result)
|
||||
return &Response{ID: id, Result: r, Error: rerr}, merr
|
||||
}
|
||||
|
||||
func (msg *Response) marshal(to *wireCombined) {
|
||||
to.ID = msg.ID.value
|
||||
to.Error = toWireError(msg.Error)
|
||||
to.Result = msg.Result
|
||||
}
|
||||
|
||||
func toWireError(err error) *WireError {
|
||||
if err == nil {
|
||||
// no error, the response is complete
|
||||
return nil
|
||||
}
|
||||
if err, ok := err.(*WireError); ok {
|
||||
// already a wire error, just use it
|
||||
return err
|
||||
}
|
||||
result := &WireError{Message: err.Error()}
|
||||
var wrapped *WireError
|
||||
if errors.As(err, &wrapped) {
|
||||
// if we wrapped a wire error, keep the code from the wrapped error
|
||||
// but the message from the outer error
|
||||
result.Code = wrapped.Code
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func EncodeMessage(msg Message) ([]byte, error) {
|
||||
wire := wireCombined{VersionTag: wireVersion}
|
||||
msg.marshal(&wire)
|
||||
data, err := jsonMarshal(&wire)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling jsonrpc message: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// EncodeIndent is like EncodeMessage, but honors indents.
|
||||
// TODO(rfindley): refactor so that this concern is handled independently.
|
||||
// Perhaps we should pass in a json.Encoder?
|
||||
func EncodeIndent(msg Message, prefix, indent string) ([]byte, error) {
|
||||
wire := wireCombined{VersionTag: wireVersion}
|
||||
msg.marshal(&wire)
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent(prefix, indent)
|
||||
if err := enc.Encode(&wire); err != nil {
|
||||
return nil, fmt.Errorf("marshaling jsonrpc message: %w", err)
|
||||
}
|
||||
return bytes.TrimRight(buf.Bytes(), "\n"), nil
|
||||
}
|
||||
|
||||
// wireDecode is the decode form of [wireCombined]. Method is a [json.RawMessage]
|
||||
// so we can tell whether the "method" key was present on the wire, including
|
||||
// when its value is the empty string (see go-sdk#976).
|
||||
type wireDecode struct {
|
||||
VersionTag string `json:"jsonrpc"`
|
||||
ID any `json:"id,omitempty"`
|
||||
Method json.RawMessage `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *WireError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func DecodeMessage(data []byte) (Message, error) {
|
||||
msg := wireDecode{}
|
||||
if err := internaljson.Unmarshal(data, &msg); err != nil {
|
||||
return nil, fmt.Errorf("unmarshaling jsonrpc message: %w", err)
|
||||
}
|
||||
if msg.VersionTag != wireVersion {
|
||||
return nil, fmt.Errorf("invalid message version tag %q; expected %q", msg.VersionTag, wireVersion)
|
||||
}
|
||||
id, err := MakeID(msg.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(msg.Method) > 0 {
|
||||
// The "method" key was present. Decode its value (including "").
|
||||
var method string
|
||||
if err := internaljson.Unmarshal(msg.Method, &method); err != nil {
|
||||
return nil, fmt.Errorf("unmarshaling jsonrpc message: %w", err)
|
||||
}
|
||||
return &Request{
|
||||
Method: method,
|
||||
ID: id,
|
||||
Params: msg.Params,
|
||||
}, nil
|
||||
}
|
||||
// no method key, should be a response
|
||||
if !id.IsValid() {
|
||||
return nil, ErrInvalidRequest
|
||||
}
|
||||
resp := &Response{
|
||||
ID: id,
|
||||
Result: msg.Result,
|
||||
}
|
||||
// we have to check if msg.Error is nil to avoid a typed error
|
||||
if msg.Error != nil {
|
||||
resp.Error = msg.Error
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func marshalToRaw(obj any) (json.RawMessage, error) {
|
||||
if obj == nil {
|
||||
return nil, nil
|
||||
}
|
||||
data, err := jsonMarshal(obj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.RawMessage(data), nil
|
||||
}
|
||||
|
||||
// jsonMarshal marshals obj to JSON like json.Marshal but without HTML escaping.
|
||||
func jsonMarshal(obj any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// json.Encoder.Encode adds a trailing newline. Trim it to be consistent with json.Marshal.
|
||||
return bytes.TrimRight(buf.Bytes(), "\n"), nil
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
// Copyright 2018 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package jsonrpc2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// This file contains the go forms of the wire specification.
|
||||
// see http://www.jsonrpc.org/specification for details
|
||||
|
||||
var (
|
||||
// ErrParse is used when invalid JSON was received by the server.
|
||||
ErrParse = NewError(-32700, "parse error")
|
||||
// ErrInvalidRequest is used when the JSON sent is not a valid Request object.
|
||||
ErrInvalidRequest = NewError(-32600, "invalid request")
|
||||
// ErrMethodNotFound should be returned by the handler when the method does
|
||||
// not exist / is not available.
|
||||
ErrMethodNotFound = NewError(-32601, "method not found")
|
||||
// ErrInvalidParams should be returned by the handler when method
|
||||
// parameter(s) were invalid.
|
||||
ErrInvalidParams = NewError(-32602, "invalid params")
|
||||
// ErrInternal indicates a failure to process a call correctly
|
||||
ErrInternal = NewError(-32603, "internal error")
|
||||
|
||||
// The following errors are not part of the json specification, but
|
||||
// compliant extensions specific to this implementation.
|
||||
|
||||
// ErrUnknown should be used for all non coded errors.
|
||||
ErrUnknown = NewError(-32001, "unknown error")
|
||||
// ErrServerClosing is returned for calls that arrive while the server is closing.
|
||||
ErrServerClosing = NewError(-32004, "server is closing")
|
||||
// ErrClientClosing is a dummy error returned for calls initiated while the client is closing.
|
||||
ErrClientClosing = NewError(-32003, "client is closing")
|
||||
|
||||
// The following errors have special semantics for MCP transports
|
||||
|
||||
// ErrRejected may be wrapped to return errors from calls to Writer.Write
|
||||
// that signal that the request was rejected by the transport layer as
|
||||
// invalid.
|
||||
//
|
||||
// Such failures do not indicate that the connection is broken, but rather
|
||||
// should be returned to the caller to indicate that the specific request is
|
||||
// invalid in the current context.
|
||||
ErrRejected = NewError(-32005, "rejected by transport")
|
||||
)
|
||||
|
||||
const wireVersion = "2.0"
|
||||
|
||||
// wireCombined has all the fields of both Request and Response.
|
||||
// We can decode this and then work out which it is.
|
||||
type wireCombined struct {
|
||||
VersionTag string `json:"jsonrpc"`
|
||||
ID any `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *WireError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// WireError represents a structured error in a Response.
|
||||
type WireError struct {
|
||||
// Code is an error code indicating the type of failure.
|
||||
Code int64 `json:"code"`
|
||||
// Message is a short description of the error.
|
||||
Message string `json:"message"`
|
||||
// Data is optional structured data containing additional information about the error.
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// NewError returns an error that will encode on the wire correctly.
|
||||
// The standard codes are made available from this package, this function should
|
||||
// only be used to build errors for application specific codes as allowed by the
|
||||
// specification.
|
||||
func NewError(code int64, message string) error {
|
||||
return &WireError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
func (err *WireError) Error() string {
|
||||
return err.Message
|
||||
}
|
||||
|
||||
func (err *WireError) Is(other error) bool {
|
||||
w, ok := other.(*WireError)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return err.Code == w.Code
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by the license
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
// Package mcpgodebug provides a mechanism to configure compatibility parameters
|
||||
// via the MCPGODEBUG environment variable.
|
||||
//
|
||||
// The value of MCPGODEBUG is a comma-separated list of key=value pairs.
|
||||
// For example:
|
||||
//
|
||||
// MCPGODEBUG=someoption=1,otheroption=value
|
||||
package mcpgodebug
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const compatibilityEnvKey = "MCPGODEBUG"
|
||||
|
||||
var compatibilityParams map[string]string
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
compatibilityParams, err = parseCompatibility(os.Getenv(compatibilityEnvKey))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Value returns the value of the compatibility parameter with the given key.
|
||||
// It returns an empty string if the key is not set.
|
||||
func Value(key string) string {
|
||||
return compatibilityParams[key]
|
||||
}
|
||||
|
||||
func parseCompatibility(envValue string) (map[string]string, error) {
|
||||
if envValue == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
params := make(map[string]string)
|
||||
for part := range strings.SplitSeq(envValue, ",") {
|
||||
k, v, ok := strings.Cut(part, "=")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("MCPGODEBUG: invalid format: %q", part)
|
||||
}
|
||||
params[strings.TrimSpace(k)] = strings.TrimSpace(v)
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user