Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ go-sdk/
├── mediatype/ # immutable MediaType + constants
├── header/ # canonical header-name constants
├── pagination/ # generic iter.Seq2 Pager
├── conditions/ # ETag, Range, Conditions value types
├── redact/ # default-deny URL redactor (userinfo + query values)
├── instrumentation/ # tracing + metrics SPIs, no-op defaults, policies
├── config/ # layered override→env→default settings
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ standard library.
| [`mediatype`](./mediatype) | Immutable media-type value with parsing and common constants. |
| [`header`](./header) | Canonical HTTP header-name constants. |
| [`pagination`](./pagination) | Generic pagination as `iter.Seq2` range-over-func iterators — cursor/token, page-number, and RFC 8288 Link-header strategies, with a `WithMaxPages` cap. |
| [`conditions`](./conditions) | Conditional- and range-request value types (ETag, Range, Conditions). |
| [`config`](./config) | Layered override → environment → default settings resolver; non-failing typed getters. |
| [`serde`](./serde) | Serialization seam (Marshaler/Unmarshaler) with a JSON default, plus Tristate for PATCH payloads. |
| root [`dexpace`](.) | Umbrella `Client` wiring the default policy stack. |
Expand Down
50 changes: 50 additions & 0 deletions conditions/conditions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) 2026 dexpace and Omar Aljarrah.
// Licensed under the MIT License. See LICENSE in the repository root for details.

package conditions

import (
"net/http"
"strings"
"time"

"github.com/dexpace/go-sdk/header"
)

// Conditions carries conditional-request headers (RFC 9110 §13). Empty ETag
// slices and zero times are left unset.
type Conditions struct {
IfMatch []ETag
IfNoneMatch []ETag
IfModifiedSince time.Time
IfUnmodifiedSince time.Time
}

// Apply sets the configured conditional headers on req. Each ETag list is
// comma-joined; times are formatted as HTTP-dates in UTC. Unset fields leave the
// corresponding header untouched; set fields overwrite any existing value.
func (c Conditions) Apply(req *http.Request) {
if v := joinETags(c.IfMatch); v != "" {
req.Header.Set(header.IfMatch, v)
}
if v := joinETags(c.IfNoneMatch); v != "" {
req.Header.Set(header.IfNoneMatch, v)
}
if !c.IfModifiedSince.IsZero() {
req.Header.Set(header.IfModifiedSince, c.IfModifiedSince.UTC().Format(http.TimeFormat))
}
if !c.IfUnmodifiedSince.IsZero() {
req.Header.Set(header.IfUnmodifiedSince, c.IfUnmodifiedSince.UTC().Format(http.TimeFormat))
}
}

func joinETags(tags []ETag) string {
if len(tags) == 0 {
return ""
}
parts := make([]string, len(tags))
for i, t := range tags {
parts[i] = t.String()
}
return strings.Join(parts, ", ")
}
74 changes: 74 additions & 0 deletions conditions/conditions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright (c) 2026 dexpace and Omar Aljarrah.
// Licensed under the MIT License. See LICENSE in the repository root for details.

package conditions_test

import (
"net/http"
"testing"
"time"

"github.com/dexpace/go-sdk/conditions"
)

func TestConditionsApplyIfNoneMatch(t *testing.T) {
t.Parallel()

req, _ := http.NewRequest(http.MethodGet, "https://api.example.test/", nil)
conditions.Conditions{
IfNoneMatch: []conditions.ETag{conditions.NewETag("a"), conditions.NewWeakETag("b")},
}.Apply(req)

if got := req.Header.Get("If-None-Match"); got != `"a", W/"b"` {
t.Fatalf("If-None-Match = %q, want \"a\", W/\"b\"", got)
}
if req.Header.Get("If-Match") != "" {
t.Fatal("If-Match should be unset")
}
}

func TestConditionsApplyModifiedSince(t *testing.T) {
t.Parallel()

ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
req, _ := http.NewRequest(http.MethodGet, "https://api.example.test/", nil)
conditions.Conditions{IfModifiedSince: ts}.Apply(req)

if got := req.Header.Get("If-Modified-Since"); got != ts.Format(http.TimeFormat) {
t.Fatalf("If-Modified-Since = %q, want %q", got, ts.Format(http.TimeFormat))
}
}

func TestConditionsApplyEmptyIsNoOp(t *testing.T) {
t.Parallel()

req, _ := http.NewRequest(http.MethodGet, "https://api.example.test/", nil)
conditions.Conditions{}.Apply(req)

for _, h := range []string{"If-Match", "If-None-Match", "If-Modified-Since", "If-Unmodified-Since"} {
if req.Header.Get(h) != "" {
t.Fatalf("%s should be unset for empty Conditions", h)
}
}
}

func TestConditionsApplyIfMatchAndUnmodified(t *testing.T) {
t.Parallel()

ts := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
req, _ := http.NewRequest(http.MethodGet, "https://api.example.test/", nil)
conditions.Conditions{
IfMatch: []conditions.ETag{conditions.NewETag("v1")},
IfUnmodifiedSince: ts,
}.Apply(req)

if got := req.Header.Get("If-Match"); got != `"v1"` {
t.Fatalf("If-Match = %q, want \"v1\"", got)
}
if got := req.Header.Get("If-Unmodified-Since"); got != ts.Format(http.TimeFormat) {
t.Fatalf("If-Unmodified-Since = %q, want %q", got, ts.Format(http.TimeFormat))
}
if req.Header.Get("If-None-Match") != "" {
t.Fatal("If-None-Match should be unset")
}
}
8 changes: 8 additions & 0 deletions conditions/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Copyright (c) 2026 dexpace and Omar Aljarrah.
// Licensed under the MIT License. See LICENSE in the repository root for details.

// Package conditions provides immutable value types for conditional and range
// requests — entity tags ([ETag]), byte ranges ([Range]), and the precondition
// header set ([Conditions]) — each of which stamps the appropriate headers on an
// *http.Request via its Apply method (or, for ETag, its String form).
package conditions
51 changes: 51 additions & 0 deletions conditions/etag.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (c) 2026 dexpace and Omar Aljarrah.
// Licensed under the MIT License. See LICENSE in the repository root for details.

package conditions

import (
"fmt"
"strings"
)

// ETag is an HTTP entity-tag validator (RFC 9110). The tag is the opaque value
// without surrounding quotes; a weak tag is rendered with a leading "W/".
type ETag struct {
tag string
weak bool
}

// NewETag returns a strong entity tag.
func NewETag(tag string) ETag { return ETag{tag: tag} }

// NewWeakETag returns a weak entity tag.
func NewWeakETag(tag string) ETag { return ETag{tag: tag, weak: true} }

// Parse parses an entity tag in wire form, "abc" or W/"abc", returning an error
// for input that is not a (optionally W/-prefixed) quoted string.
func Parse(s string) (ETag, error) {
weak := false
if rest, ok := strings.CutPrefix(s, "W/"); ok {
weak = true
s = rest
}
if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' {
return ETag{}, fmt.Errorf("conditions: invalid ETag %q", s)
}
return ETag{tag: s[1 : len(s)-1], weak: weak}, nil
}

// Tag returns the opaque tag value without quotes.
func (e ETag) Tag() string { return e.tag }

// Weak reports whether the tag is a weak validator.
func (e ETag) Weak() bool { return e.weak }

// String returns the wire form: "abc" for a strong tag, W/"abc" for a weak one.
func (e ETag) String() string {
quoted := `"` + e.tag + `"`
if e.weak {
return "W/" + quoted
}
return quoted
}
61 changes: 61 additions & 0 deletions conditions/etag_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) 2026 dexpace and Omar Aljarrah.
// Licensed under the MIT License. See LICENSE in the repository root for details.

package conditions_test

import (
"testing"

"github.com/dexpace/go-sdk/conditions"
)

func TestETagString(t *testing.T) {
t.Parallel()

if got := conditions.NewETag("abc").String(); got != `"abc"` {
t.Fatalf("strong ETag = %q, want \"abc\"", got)
}
if got := conditions.NewWeakETag("abc").String(); got != `W/"abc"` {
t.Fatalf("weak ETag = %q, want W/\"abc\"", got)
}
}

func TestETagParse(t *testing.T) {
t.Parallel()

strong, err := conditions.Parse(`"abc"`)
if err != nil {
t.Fatalf("Parse strong: %v", err)
}
if strong.Tag() != "abc" || strong.Weak() {
t.Fatalf("strong = %+v, want tag=abc weak=false", strong)
}

weak, err := conditions.Parse(`W/"abc"`)
if err != nil {
t.Fatalf("Parse weak: %v", err)
}
if weak.Tag() != "abc" || !weak.Weak() {
t.Fatalf("weak = %+v, want tag=abc weak=true", weak)
}

for _, bad := range []string{"", "abc", `"abc`, `abc"`, "W/abc"} {
if _, err := conditions.Parse(bad); err == nil {
t.Fatalf("Parse(%q) should fail", bad)
}
}
}

func TestETagRoundTrip(t *testing.T) {
t.Parallel()

for _, e := range []conditions.ETag{conditions.NewETag("x"), conditions.NewWeakETag("y")} {
got, err := conditions.Parse(e.String())
if err != nil {
t.Fatalf("Parse(%q): %v", e.String(), err)
}
if got != e {
t.Fatalf("round-trip = %+v, want %+v", got, e)
}
}
}
41 changes: 41 additions & 0 deletions conditions/range.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright (c) 2026 dexpace and Omar Aljarrah.
// Licensed under the MIT License. See LICENSE in the repository root for details.

package conditions

import (
"fmt"
"net/http"

"github.com/dexpace/go-sdk/header"
)

// Range is an HTTP byte range for the Range header (RFC 9110 §14.2).
type Range struct {
start int64
end int64
hasEnd bool
}

// Bytes returns the inclusive byte range [start, end].
func Bytes(start, end int64) Range {
return Range{start: start, end: end, hasEnd: true}
}

// BytesFrom returns the open-ended byte range [start, end-of-resource).
func BytesFrom(start int64) Range {
return Range{start: start}
}

// String returns the Range header value, "bytes=start-end" or "bytes=start-".
func (r Range) String() string {
if r.hasEnd {
return fmt.Sprintf("bytes=%d-%d", r.start, r.end)
}
return fmt.Sprintf("bytes=%d-", r.start)
}

// Apply sets the Range header on req.
func (r Range) Apply(req *http.Request) {
req.Header.Set(header.Range, r.String())
}
32 changes: 32 additions & 0 deletions conditions/range_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright (c) 2026 dexpace and Omar Aljarrah.
// Licensed under the MIT License. See LICENSE in the repository root for details.

package conditions_test

import (
"net/http"
"testing"

"github.com/dexpace/go-sdk/conditions"
)

func TestRangeString(t *testing.T) {
t.Parallel()

if got := conditions.Bytes(0, 99).String(); got != "bytes=0-99" {
t.Fatalf("Bytes(0,99) = %q, want bytes=0-99", got)
}
if got := conditions.BytesFrom(100).String(); got != "bytes=100-" {
t.Fatalf("BytesFrom(100) = %q, want bytes=100-", got)
}
}

func TestRangeApply(t *testing.T) {
t.Parallel()

req, _ := http.NewRequest(http.MethodGet, "https://api.example.test/", nil)
conditions.Bytes(0, 1023).Apply(req)
if got := req.Header.Get("Range"); got != "bytes=0-1023" {
t.Fatalf("Range header = %q, want bytes=0-1023", got)
}
}
3 changes: 3 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@
// - [github.com/dexpace/go-sdk/mediatype], [github.com/dexpace/go-sdk/header],
// [github.com/dexpace/go-sdk/pagination] — HTTP value helpers.
//
// The conditions package provides value types for conditional and range requests
// (ETag, Range, Conditions) that stamp the appropriate headers on a request.
//
// The serde package provides a serialization seam (Marshaler/Unmarshaler with a
// JSON default) and Tristate for JSON PATCH payloads; httperr.ResponseError.DecodeInto
// decodes an error body into a typed value.
Expand Down
Loading
Loading