Compare commits

...

8 Commits
0.9.2 ... 0.9.3

Author SHA1 Message Date
yusing
4d47eb0e91 update compose example 2025-02-06 05:59:21 +08:00
yusing
af7c59b5c2 add tests for rules.on 2025-02-06 05:50:03 +08:00
yusing
693bf68864 rules: updated help message, make values optional, fixes tests 2025-02-06 05:13:47 +08:00
Yuzerion
c9ddf3d165 Create FUNDING.yml 2025-02-06 04:44:19 +08:00
yusing
1549b56866 README: move auth docs to wiki 2025-02-06 03:12:34 +08:00
yusing
2cd1f22e68 add test for the previous commit 2025-02-06 02:33:30 +08:00
yusing
688f38943d fix single line yaml list treated as comma seperated list 2025-02-06 01:58:45 +08:00
yusing
043bbd7a11 readme and docker compose example amendment 2025-02-06 00:56:11 +08:00
12 changed files with 357 additions and 128 deletions

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: yusing # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: yusingwysq # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -1,19 +1,25 @@
<div align="center">
# GoDoxy
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
![GitHub last commit](https://img.shields.io/github/last-commit/yusing/go-proxy)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![](https://dcbadge.limes.pink/api/server/umReR62nRd?style=flat)](https://discord.gg/umReR62nRd)
A lightweight, simple, and [performant](https://github.com/yusing/go-proxy/wiki/Benchmarks) reverse proxy with WebUI.
For full documentation, check out **[Wiki](https://github.com/yusing/go-proxy/wiki)**
**EN** | <a href="README_CHT.md">中文</a>
<!-- [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![](https://dcbadge.limes.pink/api/server/umReR62nRd)](https://discord.gg/umReR62nRd)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy) -->
[繁體中文文檔請看此](README_CHT.md)
<img src="https://github.com/user-attachments/assets/4bb371f4-6e4c-425c-89b2-b9e962bdd46f" style="max-width: 650">
A lightweight, easy-to-use, and [performant](https://github.com/yusing/go-proxy/wiki/Benchmarks) reverse proxy with a Web UI and dashboard.
![Screenshot](https://github.com/user-attachments/assets/4bb371f4-6e4c-425c-89b2-b9e962bdd46f)
_Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
</div>
## Table of content
@@ -22,9 +28,8 @@ _Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
- [GoDoxy](#godoxy)
- [Table of content](#table-of-content)
- [Key Features](#key-features)
- [Getting Started](#getting-started)
- [Prerequisites](#prerequisites)
- [Setup](#setup)
- [Prerequisites](#prerequisites)
- [Setup](#setup)
- [Manual Setup](#manual-setup)
- [Folder structrue](#folder-structrue)
- [Use JSON Schema in VSCode](#use-json-schema-in-vscode)
@@ -53,18 +58,14 @@ _Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
[🔼Back to top](#table-of-content)
## Getting Started
For full documentation, **[See Wiki](https://github.com/yusing/go-proxy/wiki)**
### Prerequisites
## Prerequisites
Setup DNS Records point to machine which runs `GoDoxy`, e.g.
- A Record: `*.y.z` -> `10.0.10.1`
- AAAA Record: `*.y.z` -> `::ffff:a00:a01`
### Setup
## Setup
1. Pull the latest docker images
@@ -78,27 +79,11 @@ Setup DNS Records point to machine which runs `GoDoxy`, e.g.
docker run --rm -v .:/setup ghcr.io/yusing/go-proxy /app/godoxy setup
```
3. _(Optional)_ setup WebUI login (skip if you use OIDC)
3. _(Optional)_ setup `docker-socket-proxy` other docker nodes (see [Multi docker nodes setup](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)) then add them inside `config.yml`
- set random JWT secret
4. Start the container `docker compose up -d`
```shell
sed -i "s|API_JWT_SECRET=.*|API_JWT_SECRET=$(openssl rand -base64 32)|g" .env
```
- change username and password for WebUI authentication
```shell
USERNAME=admin
PASSWORD=some-password
sed -i "s|API_USERNAME=.*|API_USERNAME=${USERNAME}|g" .env
sed -i "s|API_PASSWORD=.*|API_PASSWORD=${PASSWORD}|g" .env
```
4. _(Optional)_ setup `docker-socket-proxy` other docker nodes (see [Multi docker nodes setup](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)) then add them inside `config.yml`
5. Start the container `docker compose up -d`
6. You may now do some extra configuration on WebUI `https://gp.y.z`
5. You may now do some extra configuration on WebUI `https://godoxy.domain.com`
[🔼Back to top](#table-of-content)

View File

@@ -1,19 +1,25 @@
<div align="center">
# GoDoxy
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
![GitHub last commit](https://img.shields.io/github/last-commit/yusing/go-proxy)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![](https://dcbadge.limes.pink/api/server/umReR62nRd?style=flat)](https://discord.gg/umReR62nRd)
輕量、易用、 [高效能](https://github.com/yusing/go-proxy/wiki/Benchmarks),且帶有主頁和配置面板的反向代理
完整文檔請查閱 **[Wiki](https://github.com/yusing/go-proxy/wiki)**(暫未有中文翻譯)
<!-- [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![](https://dcbadge.limes.pink/api/server/umReR62nRd)](https://discord.gg/umReR62nRd)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy) -->
[English Documentation](README.md)
<a href="README.md">EN</a> | **中文**
一個輕量級、易於使用且[高效能](https://github.com/yusing/go-proxy/wiki/Benchmarks)的反向代理,具有網頁介面和儀表板。
<img src="https://github.com/user-attachments/assets/4bb371f4-6e4c-425c-89b2-b9e962bdd46f" style="max-width: 650">
![截圖](screenshots/webui.png)
_加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
</div>
## 目錄
@@ -22,9 +28,8 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
- [GoDoxy](#godoxy)
- [目錄](#目錄)
- [主要特點](#主要特點)
- [入門指南](#入門指南)
- [前置需求](#前置需求)
- [安裝](#安裝)
- [前置需求](#前置需求)
- [安裝](#安裝)
- [手動安裝](#手動安裝)
- [資料夾結構](#資料夾結構)
- [在 VSCode 中使用 JSON Schema](#在-vscode-中使用-json-schema)
@@ -43,6 +48,7 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
- 容器狀態/配置文件變更時自動熱重載
- **閒置休眠**在閒置時停止容器有流量時喚醒_可選參見[截圖](#閒置休眠)_
- HTTP(s) 反向代理
- OpenID Connect 支持
- [HTTP 中介軟體支援](https://github.com/yusing/go-proxy/wiki/Middlewares)
- [自訂錯誤頁面支援](https://github.com/yusing/go-proxy/wiki/Middlewares#custom-error-pages)
- TCP 和 UDP 埠轉發
@@ -52,18 +58,14 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
[🔼回到頂部](#目錄)
## 入門指南
完整文檔請參見 **[Wiki](https://github.com/yusing/go-proxy/wiki)**
### 前置需求
## 前置需求
設置 DNS 記錄指向運行 `GoDoxy` 的機器,例如:
- A 記錄:`*.y.z` -> `10.0.10.1`
- AAAA 記錄:`*.y.z` -> `::ffff:a00:a01`
### 安裝
## 安裝
1. 拉取最新的 Docker 映像
@@ -77,29 +79,11 @@ _加入我們的 [Discord](https://discord.gg/umReR62nRd) 獲取幫助和討論_
docker run --rm -v .:/setup ghcr.io/yusing/go-proxy /app/godoxy setup
```
3. _可選_ 設置網頁介面登入
3. _可選_ 設置其他 Docker 節點的 `docker-socket-proxy`(參見 [多 Docker 節點設置](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)),然後在 `config.yml` 中添加它們
- 設置隨機 JWT 密鑰
4. 啟動容器 `docker compose up -d`
```shell
sed -i "s|API_JWT_SECRET=.*|API_JWT_SECRET=$(openssl rand -base64 32)|g" .env
```
- 更改網頁介面認證的使用者名稱和密碼
```shell
USERNAME=admin
PASSWORD=some-password
sed -i "s|API_USERNAME=.*|API_USERNAME=${USERNAME}|g" .env
sed -i "s|API_PASSWORD=.*|API_PASSWORD=${PASSWORD}|g" .env
```
4. _可選_ 設置其他 Docker 節點的 `docker-socket-proxy`(參見 [多 Docker 節點設置](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)),然後在 `config.yml` 中添加它們
5. 啟動容器 `docker compose up -d`
6. 現在您可以進行額外的配置
- 使用文字編輯器(如 Visual Studio Code
- 通過網頁介面 `https://gp.y.z`
5. 大功告成!可前往WebUI `https://gp.domain.com` 進行額外的配置
[🔼回到頂部](#目錄)

View File

@@ -10,11 +10,12 @@ services:
- app
# modify below to fit your needs
labels:
proxy.aliases: gp
proxy.#1.port: 3000
# proxy.#1.middlewares.cidr_whitelist.status: 403
# proxy.#1.middlewares.cidr_whitelist.message: IP not allowed
# proxy.#1.middlewares.cidr_whitelist.allow: |
proxy.aliases: godoxy
proxy.godoxy.port: 3000
# proxy.godoxy.middlewares.cidr_whitelist: |
# status: 403
# message: IP not allowed
# allow:
# - 127.0.0.1
# - 10.0.0.0/8
# - 192.168.0.0/16

View File

@@ -2,19 +2,19 @@ package http
import "net/http"
var validMethods = map[string]struct{}{
http.MethodGet: {},
http.MethodHead: {},
http.MethodPost: {},
http.MethodPut: {},
http.MethodPatch: {},
http.MethodDelete: {},
http.MethodConnect: {},
http.MethodOptions: {},
http.MethodTrace: {},
}
func IsMethodValid(method string) bool {
_, ok := validMethods[method]
return ok
switch method {
case http.MethodGet,
http.MethodHead,
http.MethodPost,
http.MethodPut,
http.MethodPatch,
http.MethodDelete,
http.MethodConnect,
http.MethodOptions,
http.MethodTrace:
return true
default:
return false
}
}

View File

@@ -11,7 +11,8 @@ var (
ErrInvalidCommandSequence = E.New("invalid command sequence")
ErrInvalidSetTarget = E.New("invalid `rule.set` target")
ErrExpectNoArg = ErrInvalidArguments.Withf("expect no arg")
ErrExpectOneArg = ErrInvalidArguments.Withf("expect 1 arg")
ErrExpectTwoArgs = ErrInvalidArguments.Withf("expect 2 args")
ErrExpectNoArg = E.New("expect no arg")
ErrExpectOneArg = E.New("expect 1 arg")
ErrExpectTwoArgs = E.New("expect 2 args")
ErrExpectKVOptionalV = E.New("expect 'key' or 'key value'")
)

View File

@@ -20,9 +20,8 @@ func (h *Help) String() string {
sb.WriteString(h.command)
sb.WriteString(" ")
for arg := range h.args {
sb.WriteRune('<')
sb.WriteString(arg)
sb.WriteString("> ")
sb.WriteString(strings.ToUpper(arg))
sb.WriteRune(' ')
}
if h.description != "" {
sb.WriteString("\n\t")
@@ -32,7 +31,7 @@ func (h *Help) String() string {
sb.WriteRune('\n')
for arg, desc := range h.args {
sb.WriteRune('\t')
sb.WriteString(arg)
sb.WriteString(strings.ToUpper(arg))
sb.WriteString(": ")
sb.WriteString(desc)
sb.WriteRune('\n')

View File

@@ -34,15 +34,25 @@ var checkers = map[string]struct {
help: Help{
command: OnHeader,
args: map[string]string{
"key": "the header key",
"value": "the header value",
"key": "the header key",
"[value]": "the header value",
},
},
validate: toStrTuple,
validate: toKVOptionalV,
builder: func(args any) CheckFunc {
k, v := args.(*StrTuple).Unpack()
if v == "" {
return func(cached Cache, r *http.Request) bool {
return len(r.Header[k]) > 0
}
}
return func(cached Cache, r *http.Request) bool {
return r.Header.Get(k) == v
for _, vv := range r.Header[k] {
if v == vv {
return true
}
}
return false
}
},
},
@@ -50,13 +60,18 @@ var checkers = map[string]struct {
help: Help{
command: OnQuery,
args: map[string]string{
"key": "the query key",
"value": "the query value",
"key": "the query key",
"[value]": "the query value",
},
},
validate: toStrTuple,
validate: toKVOptionalV,
builder: func(args any) CheckFunc {
k, v := args.(*StrTuple).Unpack()
if v == "" {
return func(cached Cache, r *http.Request) bool {
return len(cached.GetQueries(r)[k]) > 0
}
}
return func(cached Cache, r *http.Request) bool {
queries := cached.GetQueries(r)[k]
for _, query := range queries {
@@ -72,13 +87,24 @@ var checkers = map[string]struct {
help: Help{
command: OnCookie,
args: map[string]string{
"key": "the cookie key",
"value": "the cookie value",
"key": "the cookie key",
"[value]": "the cookie value",
},
},
validate: toStrTuple,
validate: toKVOptionalV,
builder: func(args any) CheckFunc {
k, v := args.(*StrTuple).Unpack()
if v == "" {
return func(cached Cache, r *http.Request) bool {
cookies := cached.GetCookies(r)
for _, cookie := range cookies {
if cookie.Name == k {
return true
}
}
return false
}
}
return func(cached Cache, r *http.Request) bool {
cookies := cached.GetCookies(r)
for _, cookie := range cookies {
@@ -95,13 +121,18 @@ var checkers = map[string]struct {
help: Help{
command: OnForm,
args: map[string]string{
"key": "the form key",
"value": "the form value",
"key": "the form key",
"[value]": "the form value",
},
},
validate: toStrTuple,
validate: toKVOptionalV,
builder: func(args any) CheckFunc {
k, v := args.(*StrTuple).Unpack()
if v == "" {
return func(cached Cache, r *http.Request) bool {
return r.FormValue(k) != ""
}
}
return func(cached Cache, r *http.Request) bool {
return r.FormValue(k) == v
}
@@ -111,13 +142,18 @@ var checkers = map[string]struct {
help: Help{
command: OnPostForm,
args: map[string]string{
"key": "the form key",
"value": "the form value",
"key": "the form key",
"[value]": "the form value",
},
},
validate: toStrTuple,
validate: toKVOptionalV,
builder: func(args any) CheckFunc {
k, v := args.(*StrTuple).Unpack()
if v == "" {
return func(cached Cache, r *http.Request) bool {
return r.PostFormValue(k) != ""
}
}
return func(cached Cache, r *http.Request) bool {
return r.PostFormValue(k) == v
}

View File

@@ -1,10 +1,15 @@
package rules
import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"testing"
E "github.com/yusing/go-proxy/internal/error"
. "github.com/yusing/go-proxy/internal/utils/testing"
"golang.org/x/crypto/bcrypt"
)
func TestParseOn(t *testing.T) {
@@ -15,25 +20,50 @@ func TestParseOn(t *testing.T) {
}{
// header
{
name: "header_valid",
name: "header_valid_kv",
input: "header Connection Upgrade",
wantErr: nil,
},
{
name: "header_invalid",
name: "header_valid_k",
input: "header Connection",
wantErr: ErrInvalidArguments,
wantErr: nil,
},
{
name: "header_missing_arg",
input: "header",
wantErr: ErrExpectKVOptionalV,
},
// query
{
name: "query_valid",
name: "query_valid_kv",
input: "query key value",
wantErr: nil,
},
{
name: "query_invalid",
name: "query_valid_k",
input: "query key",
wantErr: ErrInvalidArguments,
wantErr: nil,
},
{
name: "query_missing_arg",
input: "query",
wantErr: ErrExpectKVOptionalV,
},
{
name: "cookie_valid_kv",
input: "cookie key value",
wantErr: nil,
},
{
name: "cookie_valid_k",
input: "cookie key",
wantErr: nil,
},
{
name: "cookie_missing_arg",
input: "cookie",
wantErr: ErrExpectKVOptionalV,
},
// method
{
@@ -43,9 +73,14 @@ func TestParseOn(t *testing.T) {
},
{
name: "method_invalid",
input: "method",
input: "method invalid",
wantErr: ErrInvalidArguments,
},
{
name: "method_missing_arg",
input: "method",
wantErr: ErrExpectOneArg,
},
// path
{
name: "path_valid",
@@ -53,9 +88,9 @@ func TestParseOn(t *testing.T) {
wantErr: nil,
},
{
name: "path_invalid",
name: "path_missing_arg",
input: "path",
wantErr: ErrInvalidArguments,
wantErr: ErrExpectOneArg,
},
// remote
{
@@ -65,9 +100,14 @@ func TestParseOn(t *testing.T) {
},
{
name: "remote_invalid",
input: "remote",
input: "remote abcd",
wantErr: ErrInvalidArguments,
},
{
name: "remote_missing_arg",
input: "remote",
wantErr: ErrExpectOneArg,
},
{
name: "unknown_target",
input: "unknown",
@@ -87,3 +127,152 @@ func TestParseOn(t *testing.T) {
})
}
}
type testCorrectness struct {
name string
checker string
input *http.Request
want bool
}
func genCorrectnessTestCases(field string, genRequest func(k, v string) *http.Request) []testCorrectness {
return []testCorrectness{
{
name: field + "_match",
checker: field + " foo bar",
input: genRequest("foo", "bar"),
want: true,
},
{
name: field + "_no_match",
checker: field + " foo baz",
input: genRequest("foo", "bar"),
want: false,
},
{
name: field + "_exists",
checker: field + " foo",
input: genRequest("foo", "abcd"),
want: true,
},
{
name: field + "_not_exists",
checker: field + " foo",
input: genRequest("bar", "abcd"),
want: false,
},
}
}
func TestOnCorrectness(t *testing.T) {
tests := []testCorrectness{
{
name: "method_match",
checker: "method GET",
input: &http.Request{Method: http.MethodGet},
want: true,
},
{
name: "method_no_match",
checker: "method GET",
input: &http.Request{Method: http.MethodPost},
want: false,
},
{
name: "path_exact_match",
checker: "path /example",
input: &http.Request{
URL: &url.URL{Path: "/example"},
},
want: true,
},
{
name: "path_wildcard_match",
checker: "path /example/*",
input: &http.Request{
URL: &url.URL{Path: "/example/123"},
},
want: true,
},
{
name: "remote_match",
checker: "remote 192.168.1.0/24",
input: &http.Request{
RemoteAddr: "192.168.1.5",
},
want: true,
},
{
name: "remote_no_match",
checker: "remote 192.168.1.0/24",
input: &http.Request{
RemoteAddr: "192.168.2.5",
},
want: false,
},
{
name: "basic_auth_correct",
checker: "basic_auth user " + string(E.Must(bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost))),
input: &http.Request{
Header: http.Header{
"Authorization": {"Basic " + base64.StdEncoding.EncodeToString([]byte("user:password"))}, // "user:password"
},
},
want: true,
},
{
name: "basic_auth_incorrect",
checker: "basic_auth user " + string(E.Must(bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost))),
input: &http.Request{
Header: http.Header{
"Authorization": {"Basic " + base64.StdEncoding.EncodeToString([]byte("user:incorrect"))}, // "user:wrong"
},
},
want: false,
},
}
tests = append(tests, genCorrectnessTestCases("header", func(k, v string) *http.Request {
return &http.Request{
Header: http.Header{k: []string{v}}}
})...)
tests = append(tests, genCorrectnessTestCases("query", func(k, v string) *http.Request {
return &http.Request{
URL: &url.URL{
RawQuery: fmt.Sprintf("%s=%s", k, v),
},
}
})...)
tests = append(tests, genCorrectnessTestCases("cookie", func(k, v string) *http.Request {
return &http.Request{
Header: http.Header{
"Cookie": {fmt.Sprintf("%s=%s", k, v)},
},
}
})...)
tests = append(tests, genCorrectnessTestCases("form", func(k, v string) *http.Request {
return &http.Request{
Form: url.Values{
k: []string{v},
},
}
})...)
tests = append(tests, genCorrectnessTestCases("postform", func(k, v string) *http.Request {
return &http.Request{
PostForm: url.Values{
k: []string{v},
},
}
})...)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
on, err := parseOn(tt.checker)
ExpectNoError(t, err)
got := on.Check(Cache{}, tt.input)
if tt.want != got {
t.Errorf("want %v, got %v", tt.want, got)
}
})
}
}

View File

@@ -36,6 +36,18 @@ func toStrTuple(args []string) (any, E.Error) {
return &StrTuple{args[0], args[1]}, nil
}
// toKVOptionalV returns *StrTuple that value is optional.
func toKVOptionalV(args []string) (any, E.Error) {
switch len(args) {
case 1:
return &StrTuple{args[0], ""}, nil
case 2:
return &StrTuple{args[0], args[1]}, nil
default:
return nil, ErrExpectKVOptionalV
}
}
// validateURL returns types.URL with the URL validated.
func validateURL(args []string) (any, E.Error) {
if len(args) != 1 {

View File

@@ -428,7 +428,7 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr E.E
src = strings.TrimSpace(src)
isMultiline := strings.ContainsRune(src, '\n')
// one liner is comma separated list
if !isMultiline {
if !isMultiline && src[0] != '-' {
values := strutils.CommaSeperatedList(src)
dst.Set(reflect.MakeSlice(dst.Type(), len(values), len(values)))
errs := E.NewBuilder("invalid slice values")

View File

@@ -186,6 +186,13 @@ func TestStringToSlice(t *testing.T) {
ExpectNoError(t, err)
ExpectDeepEqual(t, dst, []string{"a", "b", "c"})
})
t.Run("single-line-yaml-like", func(t *testing.T) {
dst := make([]string, 0)
convertible, err := ConvertString("- a", reflect.ValueOf(&dst))
ExpectTrue(t, convertible)
ExpectNoError(t, err)
ExpectDeepEqual(t, dst, []string{"a"})
})
}
func BenchmarkStringToSlice(b *testing.B) {