Skip to content

Commit 9bfcdb4

Browse files
authored
Merge pull request #771 from nginx-proxy/feat/validation-functions
feat: basic template validation function
2 parents 2bf9ee4 + caed88e commit 9bfcdb4

4 files changed

Lines changed: 301 additions & 0 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,9 @@ server {{ $net.IP }}:{{ (index $value.Addresses 0).Port }};
460460
- _`groupByLabelWithDefault $containers $label $defaultValue`_: Returns the same as `groupBy` but grouping by the given label's value. Containers that do not have the `$label` set are included in the map under the `$defaultValue` key.
461461
- _`include $file`_: Returns content of `$file`, and empty string if file reading error.
462462
- _`intersect $slice1 $slice2`_: Returns the strings that exist in both string slices.
463+
- _`mustBeOneOf $slice $value`_: Validates that `$value` is one of the allowed string values in `$slice`, returns `$value` on success and an error otherwise.
464+
- _`mustBeInt $value`_: Validates that `$value` is a base-10 integer string, returns `$value` on success and an error otherwise.
465+
- _`mustBeIntInRange $min $max $value`_: Validates that `$value` is a base-10 integer string in the inclusive range `$min..$max`, returns `$value` on success and an error otherwise.
463466
- _`fromYaml $string` / `mustFromYaml $string`_: Similar to [Sprig's `fromJson` / `mustFromJson`](https://github.com/Masterminds/sprig/blob/master/docs/defaults.md#fromjson-mustfromjson), but for YAML.
464467
- _`toYaml $dict` / `mustToYaml $dict`_: Similar to [Sprig's `toJson` / `mustToJson`](https://github.com/Masterminds/sprig/blob/master/docs/defaults.md#tojson-musttojson), but for YAML.
465468
- _`keys $map`_: Returns the keys from `$map`. If `$map` is `nil`, a `nil` is returned. If `$map` is not a `map`, an error will be thrown.

internal/template/template.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ func newTemplate(name string) *template.Template {
104104
"whereLabelDoesNotExist": whereLabelDoesNotExist,
105105
"whereLabelValueMatches": whereLabelValueMatches,
106106

107+
// validation functions
108+
"mustBeOneOf": mustBeOneOf,
109+
"mustBeInt": mustBeInt,
110+
"mustBeIntInRange": mustBeIntInRange,
111+
107112
// legacy docker-gen template function aliased to their Sprig clone
108113
"json": sprigFuncMap["mustToJson"],
109114
"parseJson": sprigFuncMap["mustFromJson"],

internal/template/validation.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package template
2+
3+
import (
4+
"fmt"
5+
"math"
6+
"slices"
7+
"strconv"
8+
)
9+
10+
// mustBeOneOf validates that value matches one of the allowed string values.
11+
func mustBeOneOf(allowed []any, value string) (string, error) {
12+
strAllowed := make([]string, len(allowed))
13+
14+
for i, candidate := range allowed {
15+
strAllowedValue, ok := candidate.(string)
16+
if !ok {
17+
return "", fmt.Errorf("allowed value %v (type %T) is not a string", candidate, candidate)
18+
}
19+
strAllowed[i] = strAllowedValue
20+
}
21+
22+
if slices.Contains(strAllowed, value) {
23+
return value, nil
24+
}
25+
26+
return "", fmt.Errorf(
27+
"value must be one of %q; got %q",
28+
strAllowed,
29+
value,
30+
)
31+
}
32+
33+
// mustBeInt validates that value is a base-10 integer string.
34+
func mustBeInt(value string) (string, error) {
35+
return mustBeIntInRange(math.MinInt, math.MaxInt, value)
36+
}
37+
38+
// mustBeIntInRange validates that value is a base-10 integer string within min..max (inclusive).
39+
func mustBeIntInRange(min, max int, value string) (string, error) {
40+
if min > max {
41+
return "", fmt.Errorf("invalid allowed range %d..%d", min, max)
42+
}
43+
44+
if value == "" {
45+
return "", fmt.Errorf("value must be an integer; got an empty value")
46+
}
47+
48+
parsed, err := strconv.ParseInt(value, 10, 64)
49+
if err != nil {
50+
return "", fmt.Errorf("value must be an integer; got %q", value)
51+
}
52+
53+
if parsed < int64(min) || parsed > int64(max) {
54+
return "", fmt.Errorf("value must be an integer between %d and %d; got %q", min, max, value)
55+
}
56+
57+
return value, nil
58+
}
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
package template
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestMustBeOneOf(t *testing.T) {
9+
testCases := []struct {
10+
name string
11+
allowedValues []any
12+
input string
13+
wantValue string
14+
wantErr bool
15+
errSnippet string
16+
}{
17+
{
18+
name: "valid input value",
19+
allowedValues: []any{"a", "b", "c"},
20+
input: "a",
21+
wantValue: "a",
22+
},
23+
{
24+
name: "invalid input value",
25+
allowedValues: []any{"a", "b", "c"},
26+
input: "d",
27+
wantErr: true,
28+
errSnippet: `value must be one of ["a" "b" "c"]`,
29+
},
30+
{
31+
name: "non-string allowed value",
32+
allowedValues: []any{"a", 1, "c"},
33+
input: "a",
34+
wantErr: true,
35+
errSnippet: "is not a string",
36+
},
37+
}
38+
39+
for _, tc := range testCases {
40+
t.Run(tc.name, func(t *testing.T) {
41+
got, err := mustBeOneOf(tc.allowedValues, tc.input)
42+
43+
if tc.wantErr {
44+
if err == nil {
45+
t.Fatalf("mustBeOneOf(%v, %q) expected an error; got nil", tc.allowedValues, tc.input)
46+
}
47+
if got != "" {
48+
t.Fatalf("mustBeOneOf(%v, %q) returned unexpected value on error: %q", tc.allowedValues, tc.input, got)
49+
}
50+
if tc.errSnippet != "" && !strings.Contains(err.Error(), tc.errSnippet) {
51+
t.Fatalf("mustBeOneOf(%v, %q) error %q does not contain %q", tc.allowedValues, tc.input, err.Error(), tc.errSnippet)
52+
}
53+
return
54+
}
55+
56+
if err != nil {
57+
t.Fatalf("mustBeOneOf(%v, %q) returned unexpected error: %v", tc.allowedValues, tc.input, err)
58+
}
59+
if got != tc.wantValue {
60+
t.Fatalf("mustBeOneOf(%v, %q) returned %q; want %q", tc.allowedValues, tc.input, got, tc.wantValue)
61+
}
62+
})
63+
}
64+
}
65+
66+
func TestMustBeInt(t *testing.T) {
67+
testCases := []struct {
68+
name string
69+
input string
70+
wantValue string
71+
wantErr bool
72+
errSnippet string
73+
}{
74+
{
75+
name: "valid zero value",
76+
input: "0",
77+
wantValue: "0",
78+
},
79+
{
80+
name: "valid positive value",
81+
input: "161",
82+
wantValue: "161",
83+
},
84+
{
85+
name: "valid negative value",
86+
input: "-42",
87+
wantValue: "-42",
88+
},
89+
{
90+
name: "empty value is rejected",
91+
input: "",
92+
wantErr: true,
93+
errSnippet: "empty value",
94+
},
95+
{
96+
name: "non integer value is rejected",
97+
input: "abc",
98+
wantErr: true,
99+
errSnippet: "must be an integer",
100+
},
101+
}
102+
103+
for _, tc := range testCases {
104+
t.Run(tc.name, func(t *testing.T) {
105+
got, err := mustBeInt(tc.input)
106+
107+
if tc.wantErr {
108+
if err == nil {
109+
t.Fatalf("mustBeInt(%q) expected an error; got nil", tc.input)
110+
}
111+
if got != "" {
112+
t.Fatalf("mustBeInt(%q) returned unexpected value on error: %q", tc.input, got)
113+
}
114+
if tc.errSnippet != "" && !strings.Contains(err.Error(), tc.errSnippet) {
115+
t.Fatalf("mustBeInt(%q) error %q does not contain %q", tc.input, err.Error(), tc.errSnippet)
116+
}
117+
return
118+
}
119+
120+
if err != nil {
121+
t.Fatalf("mustBeInt(%q) returned unexpected error: %v", tc.input, err)
122+
}
123+
if got != tc.wantValue {
124+
t.Fatalf("mustBeInt(%q) returned %q; want %q", tc.input, got, tc.wantValue)
125+
}
126+
})
127+
}
128+
}
129+
130+
func TestMustBeIntInRange(t *testing.T) {
131+
testCases := []struct {
132+
name string
133+
min int
134+
max int
135+
input string
136+
wantValue string
137+
wantErr bool
138+
errSnippet string
139+
}{
140+
{
141+
name: "value in range",
142+
min: -3,
143+
max: 3,
144+
input: "2",
145+
wantValue: "2",
146+
},
147+
{
148+
name: "value at lower boundary",
149+
min: -3,
150+
max: 3,
151+
input: "-3",
152+
wantValue: "-3",
153+
},
154+
{
155+
name: "value at upper boundary",
156+
min: -3,
157+
max: 3,
158+
input: "3",
159+
wantValue: "3",
160+
},
161+
{
162+
name: "invalid allowed range",
163+
min: 5,
164+
max: -10,
165+
input: "3",
166+
wantErr: true,
167+
errSnippet: "invalid allowed range",
168+
},
169+
{
170+
name: "empty value is rejected",
171+
min: -3,
172+
max: 3,
173+
input: "",
174+
wantErr: true,
175+
errSnippet: "empty value",
176+
},
177+
{
178+
name: "non integer value is rejected",
179+
min: -3,
180+
max: 3,
181+
input: "abc",
182+
wantErr: true,
183+
errSnippet: "must be an integer",
184+
},
185+
{
186+
name: "value below minimum is rejected",
187+
min: -3,
188+
max: 3,
189+
input: "-4",
190+
wantErr: true,
191+
errSnippet: "between -3 and 3",
192+
},
193+
{
194+
name: "value above maximum is rejected",
195+
min: -3,
196+
max: 3,
197+
input: "4",
198+
wantErr: true,
199+
errSnippet: "between -3 and 3",
200+
},
201+
}
202+
203+
for _, tc := range testCases {
204+
t.Run(tc.name, func(t *testing.T) {
205+
got, err := mustBeIntInRange(tc.min, tc.max, tc.input)
206+
207+
if tc.wantErr {
208+
if err == nil {
209+
t.Fatalf("mustBeIntInRange(%d, %d, %q) expected an error; got nil", tc.min, tc.max, tc.input)
210+
}
211+
if got != "" {
212+
t.Fatalf("mustBeIntInRange(%d, %d, %q) returned unexpected value on error: %q", tc.min, tc.max, tc.input, got)
213+
}
214+
if tc.errSnippet != "" && !strings.Contains(err.Error(), tc.errSnippet) {
215+
t.Fatalf(
216+
"mustBeIntInRange(%d, %d, %q) error %q does not contain %q",
217+
tc.min,
218+
tc.max,
219+
tc.input,
220+
err.Error(),
221+
tc.errSnippet,
222+
)
223+
}
224+
return
225+
}
226+
227+
if err != nil {
228+
t.Fatalf("mustBeIntInRange(%d, %d, %q) returned unexpected error: %v", tc.min, tc.max, tc.input, err)
229+
}
230+
if got != tc.wantValue {
231+
t.Fatalf("mustBeIntInRange(%d, %d, %q) returned %q; want %q", tc.min, tc.max, tc.input, got, tc.wantValue)
232+
}
233+
})
234+
}
235+
}

0 commit comments

Comments
 (0)