test: add automated tests and fixtures
This commit is contained in:
1 parent
c4008505e6
commit
50039353ad
215 files changed
+34050
No files matched your search
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var update = flag.Bool("update", false, "用生成结果覆盖 testdata/sample 中的期望文件")
|
||||
|
||||
var sampleDir = filepath.Join("testdata", "sample")
|
||||
|
||||
func sampleOutputs(t *testing.T) []output {
|
||||
t.Helper()
|
||||
outputs, err := generateFrom(filepath.Join(sampleDir, "protocol", "idl.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
|
||||
func TestGenerateMatchesGolden(t *testing.T) {
|
||||
generated := map[string]bool{"protocol/idl.json": true}
|
||||
for _, out := range sampleOutputs(t) {
|
||||
generated[out.path] = true
|
||||
path := filepath.Join(sampleDir, filepath.FromSlash(out.path))
|
||||
if *update {
|
||||
if err := writeOutput(path, out.data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Errorf("%s 缺少期望文件:%v", out.path, err)
|
||||
continue
|
||||
}
|
||||
if !bytes.Equal(out.data, want) {
|
||||
t.Errorf("%s 与期望不一致,%s", out.path, firstDiff(out.data, want))
|
||||
}
|
||||
}
|
||||
err := filepath.WalkDir(sampleDir, func(path string, e fs.DirEntry, err error) error {
|
||||
if err != nil || e.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(sampleDir, path)
|
||||
if err == nil && !generated[filepath.ToSlash(rel)] {
|
||||
t.Errorf("期望文件 %s 没有对应的生成物", filepath.ToSlash(rel))
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func firstDiff(got, want []byte) string {
|
||||
g, w := bytes.Split(got, []byte("\n")), bytes.Split(want, []byte("\n"))
|
||||
for i := range max(len(g), len(w)) {
|
||||
var a, b []byte
|
||||
if i < len(g) {
|
||||
a = g[i]
|
||||
}
|
||||
if i < len(w) {
|
||||
b = w[i]
|
||||
}
|
||||
if !bytes.Equal(a, b) {
|
||||
return fmt.Sprintf("第 %d 行:\n生成 %q\n期望 %q", i+1, a, b)
|
||||
}
|
||||
}
|
||||
return "只有行尾不同"
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProtocolVersionIgnoresKeyOrderAndWhitespace(t *testing.T) {
|
||||
compact, err := protocolVersion([]byte(`{"a":{"x":1.0,"y":"中"},"b":[1,2]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if compact != "ce476f5a8616ebf9" {
|
||||
t.Errorf("版本 = %s,应为规范化文本 SHA-256 的前 16 个十六进制字符", compact)
|
||||
}
|
||||
reordered, err := protocolVersion([]byte("{\n \"b\": [1, 2],\n \"a\": {\"y\": \"\\u4e2d\", \"x\": 1.0}\n}\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reordered != compact {
|
||||
t.Errorf("只改了键的顺序和空白,版本却从 %s 变成了 %s", compact, reordered)
|
||||
}
|
||||
swapped, err := protocolVersion([]byte(`{"a":{"x":1.0,"y":"中"},"b":[2,1]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if swapped == compact {
|
||||
t.Error("数组元素的顺序变了,版本应随之改变")
|
||||
}
|
||||
}
|
||||
|
||||
func object(properties map[string]any, required ...string) map[string]any {
|
||||
s := map[string]any{"type": "object", "additionalProperties": false, "properties": properties}
|
||||
if len(required) > 0 {
|
||||
s["required"] = required
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func ref(name string) map[string]any {
|
||||
return map[string]any{"$ref": "#/$defs/" + name}
|
||||
}
|
||||
|
||||
func minimalIDL() map[string]any {
|
||||
return map[string]any{
|
||||
"title": "Minimal",
|
||||
"$defs": map[string]any{
|
||||
"Empty": object(map[string]any{}),
|
||||
"ErrorInfo": object(map[string]any{"message": map[string]any{"type": "string"}}, "message"),
|
||||
},
|
||||
"x-ws": map[string]any{
|
||||
"events": []any{map[string]any{"type": "error", "data": ref("ErrorInfo")}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func defs(d map[string]any) map[string]any {
|
||||
return d["$defs"].(map[string]any)
|
||||
}
|
||||
|
||||
func roomRequest(request map[string]any) map[string]any {
|
||||
return map[string]any{"type": "room.sync", "http": map[string]any{"method": "POST", "path": "/api/room/sync"}, "request": request, "response": ref("Empty")}
|
||||
}
|
||||
|
||||
func roomBinding(method, path string, properties map[string]any, required ...string) map[string]any {
|
||||
m := roomRequest(object(properties, required...))
|
||||
m["http"] = map[string]any{"method": method, "path": path}
|
||||
return m
|
||||
}
|
||||
|
||||
func bodylessEndpoint(name, method, path string) map[string]any {
|
||||
return map[string]any{"name": name, "method": method, "path": path, "auth": "none", "response": ref("Empty")}
|
||||
}
|
||||
|
||||
func str(keywords ...any) map[string]any {
|
||||
s := map[string]any{"type": "string"}
|
||||
for i := 0; i+1 < len(keywords); i += 2 {
|
||||
s[keywords[i].(string)] = keywords[i+1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func condition(value string, then map[string]any) map[string]any {
|
||||
return map[string]any{
|
||||
"if": map[string]any{"properties": map[string]any{"kind": map[string]any{"const": value}}, "required": []string{"kind"}},
|
||||
"then": then,
|
||||
}
|
||||
}
|
||||
|
||||
func conditional(then map[string]any) map[string]any {
|
||||
s := object(map[string]any{"kind": ref("Kind"), "id": str()}, "kind", "id")
|
||||
s["allOf"] = []any{condition("link", then)}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestAllOfConditions(t *testing.T) {
|
||||
d := minimalIDL()
|
||||
defs(d)["Kind"] = map[string]any{"type": "string", "enum": []string{"link", "bilibili"}}
|
||||
defs(d)["Link"] = str("x-maxBytes", 2048)
|
||||
defs(d)["Track"] = map[string]any{
|
||||
"type": "object", "additionalProperties": false,
|
||||
"properties": map[string]any{"source": ref("Kind"), "id": str()},
|
||||
"required": []string{"source", "id"},
|
||||
"allOf": []any{
|
||||
map[string]any{"if": map[string]any{"properties": map[string]any{"source": map[string]any{"const": "link"}}, "required": []string{"source"}}, "then": map[string]any{"properties": map[string]any{"id": ref("Link")}}},
|
||||
map[string]any{"if": map[string]any{"properties": map[string]any{"source": map[string]any{"const": "bilibili"}}, "required": []string{"source"}}, "then": map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^BV"}}}},
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := parseIDL(data); err != nil {
|
||||
t.Fatalf("两个条件 = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func withKind(d map[string]any) {
|
||||
defs(d)["Kind"] = map[string]any{"type": "string", "enum": []string{"link", "netease"}}
|
||||
}
|
||||
|
||||
func TestParseIDLRejects(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
change func(d map[string]any)
|
||||
want string
|
||||
}{
|
||||
{"拼错的关键字", func(d map[string]any) {
|
||||
defs(d)["Empty"].(map[string]any)["minLenght"] = 1
|
||||
}, `unknown field "minLenght"`},
|
||||
{"引用不存在的类型", func(d map[string]any) {
|
||||
defs(d)["ErrorInfo"] = object(map[string]any{"message": ref("Missing")}, "message")
|
||||
}, "引用的类型 #/$defs/Missing 不存在"},
|
||||
{"对象没有写 additionalProperties", func(d map[string]any) {
|
||||
delete(defs(d)["Empty"].(map[string]any), "additionalProperties")
|
||||
}, `"additionalProperties": false`},
|
||||
{"required 写了不存在的字段", func(d map[string]any) {
|
||||
defs(d)["ErrorInfo"] = object(map[string]any{"message": map[string]any{"type": "string"}}, "message", "code")
|
||||
}, "required 中的 code 不在 properties 里"},
|
||||
{"请求中有数组", func(d map[string]any) {
|
||||
d["x-requests"] = []any{roomRequest(object(map[string]any{
|
||||
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
}, "ids"))}
|
||||
}, "数组只能出现在响应和事件中"},
|
||||
{"请求引用的类型中有数组", func(d map[string]any) {
|
||||
defs(d)["Batch"] = object(map[string]any{
|
||||
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
}, "ids")
|
||||
d["x-requests"] = []any{roomRequest(object(map[string]any{"batch": ref("Batch")}, "batch"))}
|
||||
}, "$defs.Batch.properties.ids:数组只能出现在响应和事件中"},
|
||||
{"生成的类型名重名", func(d map[string]any) {
|
||||
defs(d)["RoomSyncRequest"] = object(map[string]any{})
|
||||
d["x-requests"] = []any{roomRequest(object(map[string]any{}))}
|
||||
}, "生成的类型名 RoomSyncRequest 与其他类型重复"},
|
||||
{"oneOf 分支没有限定字段", func(d map[string]any) {
|
||||
d["x-requests"] = []any{roomRequest(map[string]any{
|
||||
"type": "object", "additionalProperties": false,
|
||||
"properties": map[string]any{"a": map[string]any{"type": "string"}, "b": map[string]any{"type": "string"}},
|
||||
"oneOf": []any{map[string]any{"required": []string{"a"}}, map[string]any{"required": []string{"b"}}},
|
||||
})}
|
||||
}, "分支应由 required、properties"},
|
||||
{"if 判断的不是枚举字段", func(d map[string]any) {
|
||||
s := object(map[string]any{"kind": map[string]any{"type": "string"}, "id": map[string]any{"type": "string"}}, "kind", "id")
|
||||
s["allOf"] = []any{condition("link", map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^h"}}})}
|
||||
defs(d)["Track"] = s
|
||||
}, "只支持按必填的枚举字段取值判断"},
|
||||
{"GET 接口带请求体", func(d map[string]any) {
|
||||
d["x-http"] = []any{map[string]any{
|
||||
"name": "listRooms", "method": "GET", "path": "/api/rooms", "auth": "none",
|
||||
"request": object(map[string]any{}), "response": ref("Empty"),
|
||||
}}
|
||||
}, "GET 请求没有请求体"},
|
||||
{"只有路径参数名不同的重复路由", func(d map[string]any) {
|
||||
d["x-http"] = []any{bodylessEndpoint("getRoom", "GET", "/api/rooms/{ref}"), bodylessEndpoint("findRoom", "GET", "/api/rooms/{id}")}
|
||||
}, "GET /api/rooms/{id} 与接口 getRoom 的路由重复"},
|
||||
{"接口放在房间内请求的路径下", func(d map[string]any) {
|
||||
d["x-http"] = []any{bodylessEndpoint("listQueue", "GET", "/api/room/queue")}
|
||||
}, `x-http[0]:path "/api/room/queue" 是 /api/room 或在它之下`},
|
||||
{"接口的路径是 /api/room", func(d map[string]any) {
|
||||
d["x-http"] = []any{bodylessEndpoint("getRoomState", "GET", "/api/room")}
|
||||
}, `x-http[0]:path "/api/room" 是 /api/room 或在它之下`},
|
||||
{"房间内请求没有 http", func(d map[string]any) {
|
||||
m := roomRequest(object(map[string]any{}))
|
||||
delete(m, "http")
|
||||
d["x-requests"] = []any{m}
|
||||
}, "x-requests[0]:缺少 http"},
|
||||
{"连接请求带了 http", func(d map[string]any) {
|
||||
d["x-ws"].(map[string]any)["requests"] = []any{map[string]any{
|
||||
"type": "ping", "http": map[string]any{"method": "POST", "path": "/api/room/ping"},
|
||||
"request": object(map[string]any{}), "response": ref("Empty"),
|
||||
}}
|
||||
}, "x-ws.requests[0]:只在 WebSocket 上使用的请求没有 http"},
|
||||
{"房间内请求的路径不在 /api/room 之下", func(d map[string]any) {
|
||||
d["x-requests"] = []any{roomBinding("GET", "/api/rooms/sync", map[string]any{})}
|
||||
}, `x-requests[0].http:path "/api/rooms/sync" 应是 /api/room 或在它之下`},
|
||||
{"房间内请求写明 200 的状态码", func(d map[string]any) {
|
||||
m := roomBinding("GET", "/api/room", map[string]any{})
|
||||
m["http"].(map[string]any)["status"] = 200
|
||||
d["x-requests"] = []any{m}
|
||||
}, "x-requests[0].http:status 只能是 201 或 204,缺省为 200"},
|
||||
{"与事件流的路由重复", func(d map[string]any) {
|
||||
d["x-requests"] = []any{roomBinding("GET", "/api/room/events", map[string]any{})}
|
||||
}, "x-requests[0].http:GET /api/room/events 与房间的事件流重复"},
|
||||
{"两个房间内请求的路由只有参数名不同", func(d map[string]any) {
|
||||
first := roomBinding("DELETE", "/api/room/queue/{entryId}", map[string]any{"entryId": str()}, "entryId")
|
||||
second := roomBinding("DELETE", "/api/room/queue/{id}", map[string]any{"id": str()}, "id")
|
||||
second["type"] = "queue.remove"
|
||||
d["x-requests"] = []any{first, second}
|
||||
}, "x-requests[1].http:DELETE /api/room/queue/{id} 与房间内请求 room.sync 的路由重复"},
|
||||
{"路径参数不是请求中的字段", func(d map[string]any) {
|
||||
d["x-requests"] = []any{roomBinding("DELETE", "/api/room/queue/{entryId}", map[string]any{})}
|
||||
}, "x-requests[0].http:路径参数 entryId 应是请求中必填的字符串字段"},
|
||||
{"路径参数是可选字段", func(d map[string]any) {
|
||||
d["x-requests"] = []any{roomBinding("DELETE", "/api/room/queue/{entryId}", map[string]any{"entryId": str()})}
|
||||
}, "x-requests[0].http:路径参数 entryId 应是请求中必填的字符串字段"},
|
||||
{"路径参数是整数字段", func(d map[string]any) {
|
||||
d["x-requests"] = []any{roomBinding("DELETE", "/api/room/queue/{entryId}", map[string]any{"entryId": map[string]any{"type": "integer"}}, "entryId")}
|
||||
}, "x-requests[0].http:路径参数 entryId 应是请求中必填的字符串字段"},
|
||||
{"路径参数重复", func(d map[string]any) {
|
||||
d["x-requests"] = []any{roomBinding("GET", "/api/room/{id}/{id}", map[string]any{"id": str()}, "id")}
|
||||
}, "x-requests[0].http:路径参数 id 重复"},
|
||||
{"GET 请求有对象字段", func(d map[string]any) {
|
||||
withKind(d)
|
||||
defs(d)["Track"] = object(map[string]any{"source": ref("Kind"), "id": str()}, "source", "id")
|
||||
d["x-requests"] = []any{roomBinding("GET", "/api/room", map[string]any{"track": ref("Track")}, "track")}
|
||||
}, "x-requests[0].http:GET 请求的字段 track 来自路径或查询串,应是字符串、整数或枚举"},
|
||||
{"204 的房间内请求有响应", func(d map[string]any) {
|
||||
m := roomBinding("POST", "/api/room/messages", map[string]any{})
|
||||
m["http"].(map[string]any)["status"] = 204
|
||||
m["response"] = object(map[string]any{"id": str()}, "id")
|
||||
d["x-requests"] = []any{m}
|
||||
}, "x-requests[0].http:204 的请求的 response 应为空对象,HTTP 不返回它"},
|
||||
{"无法编译的 pattern", func(d map[string]any) {
|
||||
defs(d)["ErrorInfo"] = object(map[string]any{"message": map[string]any{"type": "string", "pattern": "("}}, "message")
|
||||
}, "pattern 无法编译"},
|
||||
{"整数枚举", func(d map[string]any) {
|
||||
defs(d)["Level"] = map[string]any{"type": "integer", "enum": []string{"1"}}
|
||||
}, "$defs.Level:枚举应为非空的字符串枚举"},
|
||||
{"枚举带了不支持的关键字", func(d map[string]any) {
|
||||
defs(d)["Level"] = map[string]any{"type": "string", "enum": []string{"low"}, "minLength": 1}
|
||||
}, "$defs.Level:这里不支持 minLength"},
|
||||
{"x-httpStatus 不是 HTTP 状态码", func(d map[string]any) {
|
||||
defs(d)["Code"] = map[string]any{"type": "string", "enum": []string{"NOT_FOUND"}, "x-httpStatus": map[string]int{"NOT_FOUND": 4040}}
|
||||
}, "x-httpStatus 中的 NOT_FOUND: 4040 不是枚举值或不是 HTTP 状态码"},
|
||||
{"字段名不是小驼峰式", func(d map[string]any) {
|
||||
defs(d)["ErrorInfo"] = object(map[string]any{"message": str(), "Detail": str()}, "message")
|
||||
}, "$defs.ErrorInfo.properties.Detail:字段名应为小写字母开头的驼峰式"},
|
||||
{"字段生成相同的 Go 字段名", func(d map[string]any) {
|
||||
defs(d)["ErrorInfo"] = object(map[string]any{"message": str(), "userID": str(), "userId": str()}, "message")
|
||||
}, "$defs.ErrorInfo.properties:userID 和 userId 生成相同的 Go 字段名 UserID"},
|
||||
{"请求中可以为 null 的字段", func(d map[string]any) {
|
||||
d["x-requests"] = []any{roomRequest(object(map[string]any{"note": map[string]any{"anyOf": []any{str(), map[string]any{"type": "null"}}}}, "note"))}
|
||||
}, "x-requests[0].request.properties.note:可以为 null 的字段只能出现在响应和事件中"},
|
||||
{"anyOf 的第二项不是 null", func(d map[string]any) {
|
||||
defs(d)["ErrorInfo"] = object(map[string]any{"message": map[string]any{"anyOf": []any{str(), map[string]any{"type": "integer"}}}}, "message")
|
||||
}, `anyOf 只支持 [类型, {"type": "null"}]`},
|
||||
{"数组缺少 items", func(d map[string]any) {
|
||||
defs(d)["ErrorInfo"] = object(map[string]any{"message": map[string]any{"type": "array"}}, "message")
|
||||
}, "$defs.ErrorInfo.properties.message:数组缺少 items"},
|
||||
{"请求中的布尔字段", func(d map[string]any) {
|
||||
d["x-requests"] = []any{roomRequest(object(map[string]any{"force": map[string]any{"type": "boolean"}}, "force"))}
|
||||
}, "x-requests[0].request.properties.force:请求中不支持 boolean"},
|
||||
{"不支持的类型", func(d map[string]any) {
|
||||
defs(d)["ErrorInfo"] = object(map[string]any{"message": map[string]any{"type": "object"}}, "message")
|
||||
}, `$defs.ErrorInfo.properties.message:不支持的类型 "object"`},
|
||||
{"引用带了其他关键字", func(d map[string]any) {
|
||||
defs(d)["ErrorInfo"] = object(map[string]any{"message": map[string]any{"$ref": "#/$defs/Empty", "minLength": 1}}, "message")
|
||||
}, "$defs.ErrorInfo.properties.message:这里不支持 minLength"},
|
||||
{"负的 maxLength", func(d map[string]any) {
|
||||
defs(d)["ErrorInfo"] = object(map[string]any{"message": str("maxLength", -1)}, "message")
|
||||
}, "$defs.ErrorInfo.properties.message:minLength、maxLength 和 x-maxBytes 不能是负数"},
|
||||
{"minLength 大于 maxLength", func(d map[string]any) {
|
||||
defs(d)["ErrorInfo"] = object(map[string]any{"message": str("minLength", 5, "maxLength", 4)}, "message")
|
||||
}, "$defs.ErrorInfo.properties.message:minLength 大于 maxLength"},
|
||||
{"minimum 大于 maximum", func(d map[string]any) {
|
||||
defs(d)["ErrorInfo"] = object(map[string]any{"message": str(), "page": map[string]any{"type": "integer", "minimum": 2, "maximum": 1}}, "message", "page")
|
||||
}, "$defs.ErrorInfo.properties.page:minimum 大于 maximum"},
|
||||
{"oneOf 只有一个分支", func(d map[string]any) {
|
||||
d["x-requests"] = []any{roomRequest(map[string]any{
|
||||
"type": "object", "additionalProperties": false,
|
||||
"properties": map[string]any{"a": str()},
|
||||
"oneOf": []any{map[string]any{"required": []string{"a"}, "properties": map[string]any{"a": map[string]any{}}, "additionalProperties": false}},
|
||||
})}
|
||||
}, "x-requests[0].request:oneOf 至少要有两个分支"},
|
||||
{"字段不属于任何分支", func(d map[string]any) {
|
||||
d["x-requests"] = []any{roomRequest(map[string]any{
|
||||
"type": "object", "additionalProperties": false,
|
||||
"properties": map[string]any{"a": str(), "b": str(), "c": str()},
|
||||
"oneOf": []any{
|
||||
map[string]any{"required": []string{"a"}, "properties": map[string]any{"a": map[string]any{}}, "additionalProperties": false},
|
||||
map[string]any{"required": []string{"b"}, "properties": map[string]any{"b": map[string]any{}}, "additionalProperties": false},
|
||||
},
|
||||
})}
|
||||
}, "x-requests[0].request:c 不属于 oneOf 的任何分支"},
|
||||
{"分支中的字段带了约束", func(d map[string]any) {
|
||||
d["x-requests"] = []any{roomRequest(map[string]any{
|
||||
"type": "object", "additionalProperties": false,
|
||||
"properties": map[string]any{"a": str(), "b": str()},
|
||||
"oneOf": []any{
|
||||
map[string]any{"required": []string{"a"}, "properties": map[string]any{"a": str()}, "additionalProperties": false},
|
||||
map[string]any{"required": []string{"b"}, "properties": map[string]any{"b": map[string]any{}}, "additionalProperties": false},
|
||||
},
|
||||
})}
|
||||
}, "x-requests[0].request.oneOf[0]:properties 中的 a 应为 {}"},
|
||||
{"分支的可选字段不同", func(d map[string]any) {
|
||||
d["x-requests"] = []any{roomRequest(map[string]any{
|
||||
"type": "object", "additionalProperties": false,
|
||||
"properties": map[string]any{"a": str(), "b": str(), "c": str()},
|
||||
"oneOf": []any{
|
||||
map[string]any{"required": []string{"a"}, "properties": map[string]any{"a": map[string]any{}, "c": map[string]any{}}, "additionalProperties": false},
|
||||
map[string]any{"required": []string{"b"}, "properties": map[string]any{"b": map[string]any{}}, "additionalProperties": false},
|
||||
},
|
||||
})}
|
||||
}, "x-requests[0].request.oneOf[1]:可选字段应与第一个分支相同(c),实际为()"},
|
||||
{"分支的 required 有重复的字段", func(d map[string]any) {
|
||||
d["x-requests"] = []any{roomRequest(map[string]any{
|
||||
"type": "object", "additionalProperties": false,
|
||||
"properties": map[string]any{"a": str(), "b": str()},
|
||||
"oneOf": []any{
|
||||
map[string]any{"required": []string{"a", "a"}, "properties": map[string]any{"a": map[string]any{}}, "additionalProperties": false},
|
||||
map[string]any{"required": []string{"b"}, "properties": map[string]any{"b": map[string]any{}}, "additionalProperties": false},
|
||||
},
|
||||
})}
|
||||
}, "x-requests[0].request.oneOf[0]:required 中的 a 重复"},
|
||||
{"分支中的字段是外层的必填字段", func(d map[string]any) {
|
||||
d["x-requests"] = []any{roomRequest(map[string]any{
|
||||
"type": "object", "additionalProperties": false,
|
||||
"properties": map[string]any{"a": str(), "b": str()},
|
||||
"required": []string{"a"},
|
||||
"oneOf": []any{
|
||||
map[string]any{"required": []string{"a"}, "properties": map[string]any{"a": map[string]any{}}, "additionalProperties": false},
|
||||
map[string]any{"required": []string{"b"}, "properties": map[string]any{"b": map[string]any{}}, "additionalProperties": false},
|
||||
},
|
||||
})}
|
||||
}, "x-requests[0].request.oneOf[0]:a 应是外层对象的可选字段"},
|
||||
{"allOf 元素缺少 then", func(d map[string]any) {
|
||||
withKind(d)
|
||||
s := conditional(map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^h"}}})
|
||||
delete(s["allOf"].([]any)[0].(map[string]any), "then")
|
||||
defs(d)["Track"] = s
|
||||
}, "$defs.Track.allOf[0]:要同时包含 if 和 then"},
|
||||
{"allOf 元素带了其他关键字", func(d map[string]any) {
|
||||
withKind(d)
|
||||
s := conditional(map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^h"}}})
|
||||
s["allOf"].([]any)[0].(map[string]any)["properties"] = map[string]any{}
|
||||
defs(d)["Track"] = s
|
||||
}, "$defs.Track.allOf[0]:这里不支持 properties"},
|
||||
{"空的 allOf", func(d map[string]any) {
|
||||
withKind(d)
|
||||
s := conditional(map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^h"}}})
|
||||
s["allOf"] = []any{}
|
||||
defs(d)["Track"] = s
|
||||
}, "$defs.Track:allOf 至少要有一个条件"},
|
||||
{"同一个字段的同一个取值有两个条件", func(d map[string]any) {
|
||||
withKind(d)
|
||||
s := conditional(map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^h"}}})
|
||||
s["allOf"] = append(s["allOf"].([]any), condition("link", map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^g"}}}))
|
||||
defs(d)["Track"] = s
|
||||
}, "$defs.Track.allOf[1]:与 allOf[0] 重复"},
|
||||
{"if 判断了两个字段", func(d map[string]any) {
|
||||
withKind(d)
|
||||
s := conditional(map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^h"}}})
|
||||
s["allOf"] = []any{map[string]any{
|
||||
"if": map[string]any{"properties": map[string]any{"kind": map[string]any{"const": "link"}, "id": map[string]any{"const": "x"}}, "required": []string{"kind", "id"}},
|
||||
"then": map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^h"}}},
|
||||
}}
|
||||
defs(d)["Track"] = s
|
||||
}, "$defs.Track.allOf[0].if:应写成"},
|
||||
{"if 中的 const 不是枚举的取值", func(d map[string]any) {
|
||||
withKind(d)
|
||||
s := conditional(map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^h"}}})
|
||||
s["allOf"] = []any{condition("qq", map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^h"}}})}
|
||||
defs(d)["Track"] = s
|
||||
}, `$defs.Track.allOf[0].if:"qq" 不是 #/$defs/Kind 的取值`},
|
||||
{"then 约束的不是必填的字符串字段", func(d map[string]any) {
|
||||
withKind(d)
|
||||
s := conditional(map[string]any{"properties": map[string]any{"title": map[string]any{"maxLength": 10}}})
|
||||
s["properties"].(map[string]any)["title"] = str()
|
||||
defs(d)["Track"] = s
|
||||
}, "$defs.Track.allOf[0].then.properties.title:then 只能约束必填的字符串字段"},
|
||||
{"then 中负的 x-maxBytes", func(d map[string]any) {
|
||||
withKind(d)
|
||||
defs(d)["Track"] = conditional(map[string]any{"properties": map[string]any{"id": map[string]any{"x-maxBytes": -1}}})
|
||||
}, "$defs.Track.allOf[0].then.properties.id:minLength、maxLength 和 x-maxBytes 不能是负数"},
|
||||
{"事件的 data 引用了枚举", func(d map[string]any) {
|
||||
withKind(d)
|
||||
d["x-events"] = []any{map[string]any{"type": "queue.updated", "data": ref("Kind")}}
|
||||
}, "x-events[0].data:应引用对象类型"},
|
||||
{"事件的 data 引用带了其他关键字", func(d map[string]any) {
|
||||
d["x-events"] = []any{map[string]any{"type": "queue.updated", "data": map[string]any{"$ref": "#/$defs/Empty", "description": "空"}}}
|
||||
}, "x-events[0].data:这里不支持 description"},
|
||||
{"接口名与房间请求的 operationId 重复", func(d map[string]any) {
|
||||
d["x-requests"] = []any{roomRequest(object(map[string]any{}))}
|
||||
d["x-http"] = []any{bodylessEndpoint("roomSync", "GET", "/api/rooms")}
|
||||
}, `x-http[0]:接口名 "roomSync" 格式不对,或与其他接口、房间请求的 operationId 重复`},
|
||||
{"路径不在 /api 下", func(d map[string]any) {
|
||||
d["x-http"] = []any{bodylessEndpoint("listRooms", "GET", "/rooms")}
|
||||
}, `x-http[0]:method "GET" 或 path "/rooms" 不合法`},
|
||||
{"路径参数与 client 中的标识符重名", func(d map[string]any) {
|
||||
d["x-http"] = []any{bodylessEndpoint("getRoom", "GET", "/api/rooms/{send}")}
|
||||
}, "x-http[0]:路径参数 send 重复,或与生成的 TypeScript client 中的标识符重名"},
|
||||
{"同一个路径形状用了不同的参数名", func(d map[string]any) {
|
||||
d["x-http"] = []any{bodylessEndpoint("getRoom", "GET", "/api/rooms/{ref}"), bodylessEndpoint("deleteRoom", "DELETE", "/api/rooms/{id}")}
|
||||
}, "x-http[1]:路径 /api/rooms/{id} 与 /api/rooms/{ref} 只有参数名不同"},
|
||||
{"未知的凭证", func(d map[string]any) {
|
||||
e := bodylessEndpoint("listRooms", "GET", "/api/rooms")
|
||||
e["auth"] = "ticket"
|
||||
d["x-http"] = []any{e}
|
||||
}, "x-http[0]:auth 应为 none、member 或 admin"},
|
||||
{"写明 200 的状态码", func(d map[string]any) {
|
||||
e := bodylessEndpoint("listRooms", "GET", "/api/rooms")
|
||||
e["status"] = 200
|
||||
d["x-http"] = []any{e}
|
||||
}, "x-http[0]:status 只能是 201 或 204,缺省为 200"},
|
||||
{"不是 201 的接口带 Location", func(d map[string]any) {
|
||||
e := bodylessEndpoint("deleteRooms", "DELETE", "/api/rooms")
|
||||
e["status"], e["location"] = 204, true
|
||||
delete(e, "response")
|
||||
d["x-http"] = []any{e}
|
||||
}, "x-http[0]:location 只能用于 201 的接口"},
|
||||
{"204 的接口带 response", func(d map[string]any) {
|
||||
e := bodylessEndpoint("deleteRooms", "DELETE", "/api/rooms")
|
||||
e["status"] = 204
|
||||
d["x-http"] = []any{e}
|
||||
}, "x-http[0]:204 的接口没有 response"},
|
||||
{"不是 204 的接口没有 response", func(d map[string]any) {
|
||||
e := bodylessEndpoint("deleteRooms", "DELETE", "/api/rooms")
|
||||
delete(e, "response")
|
||||
d["x-http"] = []any{e}
|
||||
}, "x-http[0].response:缺少定义"},
|
||||
{"关闭码超出范围", func(d map[string]any) {
|
||||
d["x-ws"].(map[string]any)["closeCodes"] = []any{map[string]any{"name": "gone", "code": 999}}
|
||||
}, `x-ws.closeCodes[0]:名称 "gone" 或关闭码 999 不合法或重复`},
|
||||
{"类型名与请求的常量重名", func(d map[string]any) {
|
||||
d["x-requests"] = []any{roomRequest(object(map[string]any{}))}
|
||||
defs(d)["RequestRoomSync"] = object(map[string]any{})
|
||||
}, "生成的 Go 标识符 RequestRoomSync 重复:既是类型 RequestRoomSync,也是请求 room.sync 的常量"},
|
||||
{"路径参数是 JavaScript 的保留字", func(d map[string]any) {
|
||||
d["x-http"] = []any{bodylessEndpoint("getRoom", "GET", "/api/rooms/{default}")}
|
||||
}, "x-http[0]:路径参数 default 是 JavaScript 的保留字"},
|
||||
{"标量类型不是字符串或整数", func(d map[string]any) {
|
||||
defs(d)["Score"] = map[string]any{"type": "number"}
|
||||
}, `$defs.Score:类型应为对象、字符串枚举、"string" 或 "integer"`},
|
||||
{"then 引用了整数的标量类型", func(d map[string]any) {
|
||||
withKind(d)
|
||||
defs(d)["Count"] = map[string]any{"type": "integer", "minimum": 1}
|
||||
defs(d)["Track"] = conditional(map[string]any{"properties": map[string]any{"id": ref("Count")}})
|
||||
}, "$defs.Track.allOf[0].then.properties.id:只能引用字符串的标量类型"},
|
||||
{"then 中的引用带了其他关键字", func(d map[string]any) {
|
||||
withKind(d)
|
||||
defs(d)["Link"] = str("x-maxBytes", 2048)
|
||||
defs(d)["Track"] = conditional(map[string]any{"properties": map[string]any{"id": map[string]any{"$ref": "#/$defs/Link", "minLength": 1}}})
|
||||
}, "$defs.Track.allOf[0].then.properties.id:这里不支持 minLength"},
|
||||
{"字段上的 x-url", func(d map[string]any) {
|
||||
defs(d)["ErrorInfo"] = object(map[string]any{"message": str("x-url", true)}, "message")
|
||||
}, "$defs.ErrorInfo.properties.message:这里不支持 x-url"},
|
||||
{"then 中直接写 x-url", func(d map[string]any) {
|
||||
withKind(d)
|
||||
defs(d)["Track"] = conditional(map[string]any{"properties": map[string]any{"id": map[string]any{"x-url": true}}})
|
||||
}, "$defs.Track.allOf[0].then.properties.id:这里不支持 x-url"},
|
||||
{"整数的标量类型带了 x-url", func(d map[string]any) {
|
||||
defs(d)["Count"] = map[string]any{"type": "integer", "x-url": true}
|
||||
}, "$defs.Count:这里不支持 x-url"},
|
||||
{"x-url 与 pattern 同时使用", func(d map[string]any) {
|
||||
defs(d)["Link"] = str("x-url", true, "pattern", "^https://")
|
||||
}, "$defs.Link:x-url 只能是 true,并且不能与 pattern 同时使用"},
|
||||
{"单独的大小写变体", func(d map[string]any) {
|
||||
defs(d)["ErrorInfo"] = object(map[string]any{"message": map[string]any{"type": "string", "MAXLENGTH": 5}}, "message")
|
||||
}, `$defs.ErrorInfo.properties.message:键 "MAXLENGTH" 应写作 "maxLength"`},
|
||||
{"类型名与 TypeScript 中固定的类型重名", func(d map[string]any) {
|
||||
defs(d)["ServerMessage"] = object(map[string]any{})
|
||||
}, "生成的 TypeScript 标识符 ServerMessage 重复:既是生成器写出的 ServerMessage,也是类型 ServerMessage"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
d := minimalIDL()
|
||||
tt.change(d)
|
||||
data, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = parseIDL(data)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("错误 = %v,应包含 %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIDLRejectsNullAndDuplicateKeys(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
`{"title": "T", "x-requests": [null]}`: "x-requests[0]:不能是 null",
|
||||
// encoding/json 按 Unicode 的大小写折叠匹配字段名,长 s(ſ)和 s 被视为同一个字符。
|
||||
`{"title": "T", "$defſ": {}}`: `顶层:键 "$defſ" 应写作 "$defs"`,
|
||||
`{"title": "T", "$defs": {"Empty": {"type": "object", "additionalProperties": false, "properties": {"a": {"type": "string"}, "a": {"type": "integer"}}}}}`: `$defs.Empty.properties:键 "a" 重复`,
|
||||
}
|
||||
for raw, want := range tests {
|
||||
if _, err := parseIDL([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("parseIDL(%s) 的错误 = %v,应包含 %q", raw, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeywordsCoverEverySchemaField(t *testing.T) {
|
||||
typ := reflect.TypeFor[schema]()
|
||||
for i := range typ.NumField() {
|
||||
field := typ.Field(i)
|
||||
keyword, _, _ := strings.Cut(field.Tag.Get("json"), ",")
|
||||
if keyword == "" {
|
||||
continue
|
||||
}
|
||||
var s schema
|
||||
v := reflect.ValueOf(&s).Elem().Field(i)
|
||||
switch v.Kind() {
|
||||
case reflect.String:
|
||||
v.SetString("x")
|
||||
case reflect.Pointer:
|
||||
v.Set(reflect.New(field.Type.Elem()))
|
||||
case reflect.Slice:
|
||||
v.Set(reflect.MakeSlice(field.Type, 0, 0))
|
||||
case reflect.Map:
|
||||
v.Set(reflect.MakeMap(field.Type))
|
||||
}
|
||||
if got := s.keywords(); !slices.Equal(got, []string{keyword}) {
|
||||
t.Errorf("只设置 %s 时 keywords() = %v,期望 [%s]", field.Name, got, keyword)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIDLAcceptsRoutesDifferingByMethod(t *testing.T) {
|
||||
d := minimalIDL()
|
||||
d["x-http"] = []any{bodylessEndpoint("listRooms", "GET", "/api/rooms"), bodylessEndpoint("clearRooms", "DELETE", "/api/rooms")}
|
||||
data, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := parseIDL(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseMinimal(t *testing.T, change func(d map[string]any)) *idl {
|
||||
t.Helper()
|
||||
d := minimalIDL()
|
||||
change(d)
|
||||
data, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := parseIDL(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func TestGeneratedNamesAvoidHandwrittenCode(t *testing.T) {
|
||||
dir := filepath.Join("..", "..", "protocol")
|
||||
tests := []struct {
|
||||
name string
|
||||
change func(d map[string]any)
|
||||
want string
|
||||
}{
|
||||
{"类型", func(d map[string]any) {
|
||||
defs(d)["Message"] = object(map[string]any{})
|
||||
}, "生成的 Go 标识符 Message 与 ws.go 中手写的声明重名:它是类型 Message"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := parseMinimal(t, tt.change).checkHandwritten(dir); err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("错误 = %v,应包含 %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := parseMinimal(t, func(map[string]any) {}).checkHandwritten(dir); err != nil {
|
||||
t.Errorf("最小的 IDL 不应与手写的代码重名:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var tsExport = regexp.MustCompile(`(?m)^export (?:type|interface|const|function) (\w+)`)
|
||||
|
||||
func TestTSNamesCoverGeneratedCode(t *testing.T) {
|
||||
data, err := os.ReadFile(filepath.Join("..", "..", "protocol", "idl.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, err := parseIDL(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outputs, err := generate(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, out := range outputs {
|
||||
if !strings.HasSuffix(out.path, ".ts") {
|
||||
continue
|
||||
}
|
||||
for _, m := range tsExport.FindAllSubmatch(out.data, -1) {
|
||||
name := string(m[1])
|
||||
validator, ok := strings.CutPrefix(name, "is")
|
||||
if d.named[name] == nil && !slices.Contains(tsFixedNames, name) && (!ok || !slices.Contains(d.urlDefs(), validator)) {
|
||||
t.Errorf("%s 导出的 %s 既不是 IDL 中的类型和带 x-url 的标量类型的校验函数,也不在 tsFixedNames 中", out.path, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func sampleRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(sampleDir, "protocol", "idl.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
if err := writeOutput(filepath.Join(root, "protocol", "idl.json"), data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestRunWritesThenChecks(t *testing.T) {
|
||||
root := sampleRoot(t)
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"-root", root}, &stderr); code != 0 {
|
||||
t.Fatalf("生成退出码 = %d,stderr:%s", code, &stderr)
|
||||
}
|
||||
outputs := sampleOutputs(t)
|
||||
for _, out := range outputs {
|
||||
got, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(out.path)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, out.data) {
|
||||
t.Errorf("写入的 %s 与生成结果不同", out.path)
|
||||
}
|
||||
}
|
||||
if code := run([]string{"-root", root, "-check"}, &stderr); code != 0 {
|
||||
t.Fatalf("刚生成完就检查,退出码 = %d,stderr:%s", code, &stderr)
|
||||
}
|
||||
|
||||
stale := outputs[0].path
|
||||
if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(stale)), []byte("旧内容\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stderr.Reset()
|
||||
if code := run([]string{"-root", root, "-check"}, &stderr); code != 1 {
|
||||
t.Fatalf("生成物过期时退出码 = %d", code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), stale+" 不是最新的") {
|
||||
t.Errorf("stderr 没有指出过期的文件:%s", &stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCheckReportsMissingAndUnreadableOutputs(t *testing.T) {
|
||||
root := sampleRoot(t)
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"-root", root}, &stderr); code != 0 {
|
||||
t.Fatalf("生成退出码 = %d,stderr:%s", code, &stderr)
|
||||
}
|
||||
target := filepath.Join(root, "protocol", "types.gen.go")
|
||||
if err := os.Remove(target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := run([]string{"-root", root, "-check"}, &stderr); code != 1 || !strings.Contains(stderr.String(), "protocol/types.gen.go 不是最新的") {
|
||||
t.Fatalf("缺少生成物时退出码 = %d,stderr:%s", code, &stderr)
|
||||
}
|
||||
if err := os.Mkdir(target, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stderr.Reset()
|
||||
if code := run([]string{"-root", root, "-check"}, &stderr); code != 1 {
|
||||
t.Fatalf("生成物读不出来时退出码 = %d", code)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "读取 protocol/types.gen.go") || strings.Contains(got, "不是最新的") {
|
||||
t.Errorf("stderr = %s,期望报告读取的错误", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReportsInvalidIDL(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := writeOutput(filepath.Join(root, "protocol", "idl.json"), []byte(`{"title": "Broken", "$defs": {}}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"-root", root}, &stderr); code != 1 {
|
||||
t.Fatalf("退出码 = %d", code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "缺少对象类型 Empty") {
|
||||
t.Errorf("stderr = %s", &stderr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
|
||||
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var (
|
||||
patternAdminUpdateRoomRequestNote = regexp.MustCompile("^[^`]*$")
|
||||
patternAdminUpdateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{0,32}$`)
|
||||
patternTrackIDWhenLink = regexp.MustCompile(`^https?://\S+$`)
|
||||
patternTrackIDWhenNetease = regexp.MustCompile(`^[0-9]+$`)
|
||||
)
|
||||
|
||||
func decodeConnectionRequest(typ string, raw json.RawMessage, path string) (any, bool, error) {
|
||||
switch typ {
|
||||
case RequestHello:
|
||||
v, err := decodeHelloRequest(raw, path)
|
||||
return v, true, err
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func decodeRoomRequest(typ string, raw json.RawMessage, path string) (any, bool, error) {
|
||||
switch typ {
|
||||
case RequestRoomSync:
|
||||
v, err := decodeRoomSyncRequest(raw, path)
|
||||
return v, true, err
|
||||
case RequestQueueAdd:
|
||||
v, err := decodeQueueAddRequest(raw, path)
|
||||
return v, true, err
|
||||
case RequestQueueRemove:
|
||||
v, err := decodeQueueRemoveRequest(raw, path)
|
||||
return v, true, err
|
||||
case RequestQueueMove:
|
||||
v, err := decodeQueueMoveRequest(raw, path)
|
||||
return v, true, err
|
||||
case RequestSearchTracks:
|
||||
v, err := decodeSearchTracksRequest(raw, path)
|
||||
return v, true, err
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func DecodeCreateMemberRequest(body []byte) (CreateMemberRequest, error) {
|
||||
return decodeCreateMemberRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeCreateRoomRequest(body []byte) (CreateRoomRequest, error) {
|
||||
return decodeCreateRoomRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeAdminUpdateRoomRequest(body []byte) (AdminUpdateRoomRequest, error) {
|
||||
return decodeAdminUpdateRoomRequest(body, "")
|
||||
}
|
||||
|
||||
func decodeAdminUpdateRoomRequest(raw json.RawMessage, path string) (AdminUpdateRoomRequest, error) {
|
||||
var v AdminUpdateRoomRequest
|
||||
m, err := decodeObject(raw, path, "homepage", "limit", "name", "note", "slug")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if r, ok := m["homepage"]; ok {
|
||||
x, err := decodeString(r, field(path, "homepage"))
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxLength(x, field(path, "homepage"), 200); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxBytes(x, field(path, "homepage"), 600); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkURL(x, field(path, "homepage")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
v.Homepage = &x
|
||||
}
|
||||
if r, ok := m["limit"]; ok {
|
||||
x, err := decodeInteger(r, field(path, "limit"))
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinimum(x, field(path, "limit"), 1); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaximum(x, field(path, "limit"), 100); err != nil {
|
||||
return v, err
|
||||
}
|
||||
v.Limit = &x
|
||||
}
|
||||
if r, ok := m["name"]; ok {
|
||||
x, err := decodeString(r, field(path, "name"))
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinLength(x, field(path, "name"), 1); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxLength(x, field(path, "name"), 80); err != nil {
|
||||
return v, err
|
||||
}
|
||||
v.Name = &x
|
||||
}
|
||||
if r, ok := m["note"]; ok {
|
||||
x, err := decodeString(r, field(path, "note"))
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkPattern(x, field(path, "note"), patternAdminUpdateRoomRequestNote); err != nil {
|
||||
return v, err
|
||||
}
|
||||
v.Note = &x
|
||||
}
|
||||
if r, ok := m["slug"]; ok {
|
||||
x, err := decodeString(r, field(path, "slug"))
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkPattern(x, field(path, "slug"), patternAdminUpdateRoomRequestSlug); err != nil {
|
||||
return v, err
|
||||
}
|
||||
v.Slug = &x
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeCreateMemberRequest(raw json.RawMessage, path string) (CreateMemberRequest, error) {
|
||||
var v CreateMemberRequest
|
||||
_, err := decodeObject(raw, path)
|
||||
return v, err
|
||||
}
|
||||
|
||||
func decodeCreateRoomRequest(raw json.RawMessage, path string) (CreateRoomRequest, error) {
|
||||
var v CreateRoomRequest
|
||||
m, err := decodeObject(raw, path, "name")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "name"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Name, err = decodeString(m["name"], field(path, "name")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinLength(v.Name, field(path, "name"), 1); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeHelloRequest(raw json.RawMessage, path string) (HelloRequest, error) {
|
||||
var v HelloRequest
|
||||
m, err := decodeObject(raw, path, "clientTimeMs", "roomId")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "clientTimeMs", "roomId"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.ClientTimeMs, err = decodeInteger(m["clientTimeMs"], field(path, "clientTimeMs")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinimum(v.ClientTimeMs, field(path, "clientTimeMs"), 0); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.RoomID, err = decodeString(m["roomId"], field(path, "roomId")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeQueueAddRequest(raw json.RawMessage, path string) (QueueAddRequest, error) {
|
||||
var v QueueAddRequest
|
||||
m, err := decodeObject(raw, path, "keyword", "requester", "source", "track")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkOneOf(m, path, "只能提供以下字段组合之一:track;source、keyword", []string{"requester"}, []string{"track"}, []string{"source", "keyword"}); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if r, ok := m["keyword"]; ok {
|
||||
x, err := decodeString(r, field(path, "keyword"))
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinLength(x, field(path, "keyword"), 1); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxLength(x, field(path, "keyword"), 500); err != nil {
|
||||
return v, err
|
||||
}
|
||||
v.Keyword = &x
|
||||
}
|
||||
if r, ok := m["requester"]; ok {
|
||||
x, err := decodeString(r, field(path, "requester"))
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinLength(x, field(path, "requester"), 1); err != nil {
|
||||
return v, err
|
||||
}
|
||||
v.Requester = &x
|
||||
}
|
||||
if r, ok := m["source"]; ok {
|
||||
x, err := decodeSource(r, field(path, "source"))
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
v.Source = &x
|
||||
}
|
||||
if r, ok := m["track"]; ok {
|
||||
x, err := decodeTrack(r, field(path, "track"))
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
v.Track = &x
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeQueueMoveRequest(raw json.RawMessage, path string) (QueueMoveRequest, error) {
|
||||
var v QueueMoveRequest
|
||||
m, err := decodeObject(raw, path, "entryId", "position")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "entryId", "position"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.EntryID, err = decodeString(m["entryId"], field(path, "entryId")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Position, err = decodeInteger(m["position"], field(path, "position")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinimum(v.Position, field(path, "position"), 1); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeQueueRemoveRequest(raw json.RawMessage, path string) (QueueRemoveRequest, error) {
|
||||
var v QueueRemoveRequest
|
||||
m, err := decodeObject(raw, path, "entryId")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "entryId"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.EntryID, err = decodeString(m["entryId"], field(path, "entryId")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeRoomSyncRequest(raw json.RawMessage, path string) (RoomSyncRequest, error) {
|
||||
var v RoomSyncRequest
|
||||
_, err := decodeObject(raw, path)
|
||||
return v, err
|
||||
}
|
||||
|
||||
func decodeSearchTracksRequest(raw json.RawMessage, path string) (SearchTracksRequest, error) {
|
||||
var v SearchTracksRequest
|
||||
m, err := decodeObject(raw, path, "keyword", "limit", "source")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "keyword", "source"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Keyword, err = decodeString(m["keyword"], field(path, "keyword")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinLength(v.Keyword, field(path, "keyword"), 1); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxLength(v.Keyword, field(path, "keyword"), 500); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if r, ok := m["limit"]; ok {
|
||||
x, err := decodeInteger(r, field(path, "limit"))
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinimum(x, field(path, "limit"), 1); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaximum(x, field(path, "limit"), 100); err != nil {
|
||||
return v, err
|
||||
}
|
||||
v.Limit = &x
|
||||
}
|
||||
if v.Source, err = decodeSource(m["source"], field(path, "source")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeSource(raw json.RawMessage, path string) (Source, error) {
|
||||
s, err := decodeString(raw, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch v := Source(s); v {
|
||||
case SourceNetease, SourceQQ, SourceLink:
|
||||
return v, nil
|
||||
}
|
||||
return "", invalid(path, "取值应为 netease、qq、link 之一")
|
||||
}
|
||||
|
||||
func decodeTrack(raw json.RawMessage, path string) (Track, error) {
|
||||
var v Track
|
||||
m, err := decodeObject(raw, path, "id", "source")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "id", "source"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.ID, err = decodeString(m["id"], field(path, "id")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Source, err = decodeSource(m["source"], field(path, "source")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Source == SourceLink {
|
||||
if err = checkMaxBytes(v.ID, field(path, "id"), 2048); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkPattern(v.ID, field(path, "id"), patternTrackIDWhenLink); err != nil {
|
||||
return v, err
|
||||
}
|
||||
}
|
||||
if v.Source == SourceNetease {
|
||||
if err = checkPattern(v.ID, field(path, "id"), patternTrackIDWhenNetease); err != nil {
|
||||
return v, err
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Sample",
|
||||
"$defs": {
|
||||
"Empty": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
},
|
||||
"ErrorCode": {
|
||||
"type": "string",
|
||||
"enum": ["INVALID_ARGUMENT", "NOT_FOUND", "CLIENT_OUTDATED"],
|
||||
"x-httpStatus": {"INVALID_ARGUMENT": 400, "NOT_FOUND": 404}
|
||||
},
|
||||
"ErrorInfo": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"code": {"$ref": "#/$defs/ErrorCode"},
|
||||
"message": {"type": "string"}
|
||||
},
|
||||
"required": ["code", "message"]
|
||||
},
|
||||
"Keyword": {"type": "string", "minLength": 1, "maxLength": 500},
|
||||
"Limit": {"type": "integer", "description": "条数上限", "minimum": 1, "maximum": 100},
|
||||
"Website": {"type": "string", "maxLength": 200, "x-maxBytes": 600, "x-url": true},
|
||||
"Member": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"name": {"type": "string"}
|
||||
},
|
||||
"required": ["id", "name"]
|
||||
},
|
||||
"Room": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "房间",
|
||||
"properties": {
|
||||
"gain": {"type": "number"},
|
||||
"id": {"type": "string"},
|
||||
"members": {"type": "array", "items": {"$ref": "#/$defs/Member"}},
|
||||
"open": {"type": "boolean"},
|
||||
"slug": {"anyOf": [{"type": "string"}, {"type": "null"}]}
|
||||
},
|
||||
"required": ["gain", "id", "members", "open", "slug"]
|
||||
},
|
||||
"Source": {
|
||||
"type": "string",
|
||||
"enum": ["netease", "qq", "link"]
|
||||
},
|
||||
"Track": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"source": {"$ref": "#/$defs/Source"}
|
||||
},
|
||||
"required": ["id", "source"],
|
||||
"allOf": [
|
||||
{
|
||||
"if": {"properties": {"source": {"const": "link"}}, "required": ["source"]},
|
||||
"then": {"properties": {"id": {"pattern": "^https?://\\S+$", "x-maxBytes": 2048}}}
|
||||
},
|
||||
{
|
||||
"if": {"properties": {"source": {"const": "netease"}}, "required": ["source"]},
|
||||
"then": {"properties": {"id": {"pattern": "^[0-9]+$"}}}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"x-ws": {
|
||||
"requests": [
|
||||
{
|
||||
"type": "hello",
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"clientTimeMs": {"type": "integer", "minimum": 0},
|
||||
"roomId": {"type": "string"}
|
||||
},
|
||||
"required": ["clientTimeMs", "roomId"]
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"owner": {"anyOf": [{"$ref": "#/$defs/Member"}, {"type": "null"}]},
|
||||
"room": {"$ref": "#/$defs/Room"}
|
||||
},
|
||||
"required": ["owner", "room"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{"type": "error", "data": {"$ref": "#/$defs/ErrorInfo"}}
|
||||
],
|
||||
"closeCodes": [
|
||||
{"name": "invalidArgument", "code": 1008},
|
||||
{"name": "clientOutdated", "code": 4426}
|
||||
]
|
||||
},
|
||||
"x-requests": [
|
||||
{
|
||||
"type": "room.sync",
|
||||
"description": "获取房间快照",
|
||||
"http": {"method": "GET", "path": "/api/room"},
|
||||
"request": {"type": "object", "additionalProperties": false, "properties": {}},
|
||||
"response": {"$ref": "#/$defs/Room"}
|
||||
},
|
||||
{
|
||||
"type": "queue.add",
|
||||
"http": {"method": "POST", "path": "/api/room/queue", "status": 201, "location": true},
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"keyword": {"$ref": "#/$defs/Keyword"},
|
||||
"requester": {"type": "string", "minLength": 1},
|
||||
"source": {"$ref": "#/$defs/Source"},
|
||||
"track": {"$ref": "#/$defs/Track"}
|
||||
},
|
||||
"oneOf": [
|
||||
{"required": ["track"], "properties": {"track": {}, "requester": {}}, "additionalProperties": false},
|
||||
{"required": ["source", "keyword"], "properties": {"source": {}, "keyword": {}, "requester": {}}, "additionalProperties": false}
|
||||
]
|
||||
},
|
||||
"response": {"$ref": "#/$defs/Empty"}
|
||||
},
|
||||
{
|
||||
"type": "queue.remove",
|
||||
"http": {"method": "DELETE", "path": "/api/room/queue/{entryId}", "status": 204},
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"entryId": {"type": "string"}
|
||||
},
|
||||
"required": ["entryId"]
|
||||
},
|
||||
"response": {"$ref": "#/$defs/Empty"}
|
||||
},
|
||||
{
|
||||
"type": "queue.move",
|
||||
"http": {"method": "PATCH", "path": "/api/room/queue/{entryId}", "status": 204},
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"entryId": {"type": "string"},
|
||||
"position": {"type": "integer", "minimum": 1}
|
||||
},
|
||||
"required": ["entryId", "position"]
|
||||
},
|
||||
"response": {"$ref": "#/$defs/Empty"}
|
||||
},
|
||||
{
|
||||
"type": "search.tracks",
|
||||
"http": {"method": "GET", "path": "/api/room/search/tracks"},
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"keyword": {"$ref": "#/$defs/Keyword"},
|
||||
"limit": {"$ref": "#/$defs/Limit"},
|
||||
"source": {"$ref": "#/$defs/Source"}
|
||||
},
|
||||
"required": ["keyword", "source"]
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"total": {"type": "integer"}
|
||||
},
|
||||
"required": ["total"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-events": [
|
||||
{
|
||||
"type": "members.updated",
|
||||
"data": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"members": {"type": "array", "items": {"$ref": "#/$defs/Member"}},
|
||||
"note": {"type": "string"}
|
||||
},
|
||||
"required": ["members"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-http": [
|
||||
{
|
||||
"name": "createMember",
|
||||
"description": "创建成员",
|
||||
"method": "POST",
|
||||
"path": "/api/members",
|
||||
"auth": "none",
|
||||
"status": 201,
|
||||
"request": {"type": "object", "additionalProperties": false, "properties": {}},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"member": {"$ref": "#/$defs/Member"},
|
||||
"token": {"type": "string"}
|
||||
},
|
||||
"required": ["member", "token"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "getRoom",
|
||||
"method": "GET",
|
||||
"path": "/api/rooms/{ref}",
|
||||
"auth": "none",
|
||||
"response": {"$ref": "#/$defs/Room"}
|
||||
},
|
||||
{
|
||||
"name": "createRoom",
|
||||
"method": "POST",
|
||||
"path": "/api/rooms",
|
||||
"auth": "none",
|
||||
"status": 201,
|
||||
"location": true,
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"required": ["name"]
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {"type": "string"}
|
||||
},
|
||||
"required": ["id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "getSeat",
|
||||
"description": "座位上的成员,<b>座位号</b> 从 1 开始 & 不超过 8",
|
||||
"method": "GET",
|
||||
"path": "/api/rooms/{ref}/seats/{seat}",
|
||||
"auth": "member",
|
||||
"response": {"$ref": "#/$defs/Member"}
|
||||
},
|
||||
{
|
||||
"name": "adminUpdateRoom",
|
||||
"method": "PATCH",
|
||||
"path": "/api/admin/rooms/{id}",
|
||||
"auth": "admin",
|
||||
"status": 204,
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"homepage": {"$ref": "#/$defs/Website"},
|
||||
"limit": {"$ref": "#/$defs/Limit", "description": "一页最多返回的条数"},
|
||||
"name": {"type": "string", "minLength": 1, "maxLength": 80},
|
||||
"note": {"type": "string", "pattern": "^[^`]*$"},
|
||||
"slug": {"type": "string", "pattern": "^[a-z0-9-]{0,32}$"}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,779 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"AdminUpdateRoomRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"homepage": {
|
||||
"format": "iri",
|
||||
"maxLength": 200,
|
||||
"type": "string",
|
||||
"x-maxBytes": 600,
|
||||
"x-url": true
|
||||
},
|
||||
"limit": {
|
||||
"description": "一页最多返回的条数",
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"maxLength": 80,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"note": {
|
||||
"pattern": "^[^`]*$",
|
||||
"type": "string"
|
||||
},
|
||||
"slug": {
|
||||
"pattern": "^[a-z0-9-]{0,32}$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"CreateMemberRequest": {
|
||||
"additionalProperties": false,
|
||||
"type": "object"
|
||||
},
|
||||
"CreateMemberResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"member": {
|
||||
"$ref": "#/components/schemas/Member"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"member",
|
||||
"token"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"CreateRoomRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"CreateRoomResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Empty": {
|
||||
"additionalProperties": false,
|
||||
"type": "object"
|
||||
},
|
||||
"ErrorCode": {
|
||||
"enum": [
|
||||
"INVALID_ARGUMENT",
|
||||
"NOT_FOUND",
|
||||
"CLIENT_OUTDATED"
|
||||
],
|
||||
"type": "string",
|
||||
"x-httpStatus": {
|
||||
"INVALID_ARGUMENT": 400,
|
||||
"NOT_FOUND": 404
|
||||
}
|
||||
},
|
||||
"ErrorInfo": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"code": {
|
||||
"$ref": "#/components/schemas/ErrorCode"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ErrorResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"error": {
|
||||
"$ref": "#/components/schemas/ErrorInfo"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"error"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Member": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"QueueAddRequest": {
|
||||
"additionalProperties": false,
|
||||
"oneOf": [
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"requester": {},
|
||||
"track": {}
|
||||
},
|
||||
"required": [
|
||||
"track"
|
||||
]
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"keyword": {},
|
||||
"requester": {},
|
||||
"source": {}
|
||||
},
|
||||
"required": [
|
||||
"source",
|
||||
"keyword"
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"keyword": {
|
||||
"maxLength": 500,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"requester": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/components/schemas/Source"
|
||||
},
|
||||
"track": {
|
||||
"$ref": "#/components/schemas/Track"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"Room": {
|
||||
"additionalProperties": false,
|
||||
"description": "房间",
|
||||
"properties": {
|
||||
"gain": {
|
||||
"type": "number"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"members": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Member"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"open": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"slug": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"gain",
|
||||
"id",
|
||||
"members",
|
||||
"open",
|
||||
"slug"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SearchTracksResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"total": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"total"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Source": {
|
||||
"enum": [
|
||||
"netease",
|
||||
"qq",
|
||||
"link"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"Track": {
|
||||
"additionalProperties": false,
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"source": {
|
||||
"const": "link"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"source"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"pattern": "^https?://\\S+$",
|
||||
"x-maxBytes": 2048
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"source": {
|
||||
"const": "netease"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"source"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"pattern": "^[0-9]+$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/components/schemas/Source"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"source"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
"adminToken": {
|
||||
"scheme": "bearer",
|
||||
"type": "http"
|
||||
},
|
||||
"memberToken": {
|
||||
"scheme": "bearer",
|
||||
"type": "http"
|
||||
},
|
||||
"ticket": {
|
||||
"description": "进房票据或常驻房间的 API Key",
|
||||
"scheme": "bearer",
|
||||
"type": "http"
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"title": "Sample",
|
||||
"version": "362d144af81ba8bf"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
"/api/admin/rooms/{id}": {
|
||||
"patch": {
|
||||
"operationId": "adminUpdateRoom",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AdminUpdateRoomRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"adminToken": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/members": {
|
||||
"post": {
|
||||
"operationId": "createMember",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateMemberRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateMemberResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [],
|
||||
"summary": "创建成员"
|
||||
}
|
||||
},
|
||||
"/api/room": {
|
||||
"get": {
|
||||
"operationId": "roomSync",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Room"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"ticket": []
|
||||
}
|
||||
],
|
||||
"summary": "获取房间快照"
|
||||
}
|
||||
},
|
||||
"/api/room/queue": {
|
||||
"post": {
|
||||
"operationId": "queueAdd",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/QueueAddRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Empty"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "成功",
|
||||
"headers": {
|
||||
"Location": {
|
||||
"description": "新资源的地址",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"ticket": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/room/queue/{entryId}": {
|
||||
"delete": {
|
||||
"operationId": "queueRemove",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "entryId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"ticket": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "queueMove",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "entryId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"position": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"ticket": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/room/search/tracks": {
|
||||
"get": {
|
||||
"operationId": "searchTracks",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "keyword",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"maxLength": 500,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "limit",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"description": "条数上限",
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "source",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Source"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SearchTracksResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"ticket": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/rooms": {
|
||||
"post": {
|
||||
"operationId": "createRoom",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateRoomRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateRoomResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "成功",
|
||||
"headers": {
|
||||
"Location": {
|
||||
"description": "新资源的地址",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": []
|
||||
}
|
||||
},
|
||||
"/api/rooms/{ref}": {
|
||||
"get": {
|
||||
"operationId": "getRoom",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "ref",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Room"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": []
|
||||
}
|
||||
},
|
||||
"/api/rooms/{ref}/seats/{seat}": {
|
||||
"get": {
|
||||
"operationId": "getSeat",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "ref",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "seat",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Member"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"memberToken": []
|
||||
}
|
||||
],
|
||||
"summary": "座位上的成员,<b>座位号</b> 从 1 开始 & 不超过 8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
|
||||
|
||||
package protocol
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const Version = "362d144af81ba8bf"
|
||||
|
||||
type AdminUpdateRoomRequest struct {
|
||||
Homepage *string `json:"homepage,omitempty"`
|
||||
Limit *int64 `json:"limit,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Note *string `json:"note,omitempty"`
|
||||
Slug *string `json:"slug,omitempty"`
|
||||
}
|
||||
|
||||
type CreateMemberRequest struct{}
|
||||
|
||||
type CreateMemberResponse struct {
|
||||
Member Member `json:"member"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type CreateRoomRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type CreateRoomResponse struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type Empty struct{}
|
||||
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
ErrorCodeInvalidArgument ErrorCode = "INVALID_ARGUMENT"
|
||||
ErrorCodeNotFound ErrorCode = "NOT_FOUND"
|
||||
ErrorCodeClientOutdated ErrorCode = "CLIENT_OUTDATED"
|
||||
)
|
||||
|
||||
type ErrorInfo struct {
|
||||
Code ErrorCode `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type HelloRequest struct {
|
||||
ClientTimeMs int64 `json:"clientTimeMs"`
|
||||
RoomID string `json:"roomId"`
|
||||
}
|
||||
|
||||
type HelloResponse struct {
|
||||
Owner *Member `json:"owner"`
|
||||
Room Room `json:"room"`
|
||||
}
|
||||
|
||||
type Member struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type MembersUpdatedEvent struct {
|
||||
Members []Member `json:"members"`
|
||||
Note *string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
func (v MembersUpdatedEvent) MarshalJSON() ([]byte, error) {
|
||||
type plain MembersUpdatedEvent
|
||||
if v.Members == nil {
|
||||
v.Members = []Member{}
|
||||
}
|
||||
return json.Marshal(plain(v))
|
||||
}
|
||||
|
||||
type QueueAddRequest struct {
|
||||
Keyword *string `json:"keyword,omitempty"`
|
||||
Requester *string `json:"requester,omitempty"`
|
||||
Source *Source `json:"source,omitempty"`
|
||||
Track *Track `json:"track,omitempty"`
|
||||
}
|
||||
|
||||
type QueueMoveRequest struct {
|
||||
EntryID string `json:"entryId"`
|
||||
Position int64 `json:"position"`
|
||||
}
|
||||
|
||||
type QueueRemoveRequest struct {
|
||||
EntryID string `json:"entryId"`
|
||||
}
|
||||
|
||||
type Room struct {
|
||||
Gain float64 `json:"gain"`
|
||||
ID string `json:"id"`
|
||||
Members []Member `json:"members"`
|
||||
Open bool `json:"open"`
|
||||
Slug *string `json:"slug"`
|
||||
}
|
||||
|
||||
func (v Room) MarshalJSON() ([]byte, error) {
|
||||
type plain Room
|
||||
if v.Members == nil {
|
||||
v.Members = []Member{}
|
||||
}
|
||||
return json.Marshal(plain(v))
|
||||
}
|
||||
|
||||
type RoomSyncRequest struct{}
|
||||
|
||||
type SearchTracksRequest struct {
|
||||
Keyword string `json:"keyword"`
|
||||
Limit *int64 `json:"limit,omitempty"`
|
||||
Source Source `json:"source"`
|
||||
}
|
||||
|
||||
type SearchTracksResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type Source string
|
||||
|
||||
const (
|
||||
SourceNetease Source = "netease"
|
||||
SourceQQ Source = "qq"
|
||||
SourceLink Source = "link"
|
||||
)
|
||||
|
||||
type Track struct {
|
||||
ID string `json:"id"`
|
||||
Source Source `json:"source"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error ErrorInfo `json:"error"`
|
||||
}
|
||||
|
||||
const (
|
||||
RequestHello = "hello"
|
||||
RequestRoomSync = "room.sync"
|
||||
RequestQueueAdd = "queue.add"
|
||||
RequestQueueRemove = "queue.remove"
|
||||
RequestQueueMove = "queue.move"
|
||||
RequestSearchTracks = "search.tracks"
|
||||
)
|
||||
|
||||
const (
|
||||
EventError = "error"
|
||||
EventMembersUpdated = "members.updated"
|
||||
)
|
||||
|
||||
const (
|
||||
CloseInvalidArgument = 1008
|
||||
CloseClientOutdated = 4426
|
||||
)
|
||||
|
||||
var (
|
||||
RouteCreateMember = Route{Pattern: "POST /api/members", Auth: AuthNone, Status: 201}
|
||||
RouteGetRoom = Route{Pattern: "GET /api/rooms/{ref}", Auth: AuthNone, Status: 200}
|
||||
RouteCreateRoom = Route{Pattern: "POST /api/rooms", Auth: AuthNone, Status: 201, Location: true}
|
||||
RouteGetSeat = Route{Pattern: "GET /api/rooms/{ref}/seats/{seat}", Auth: AuthMember, Status: 200}
|
||||
RouteAdminUpdateRoom = Route{Pattern: "PATCH /api/admin/rooms/{id}", Auth: AuthAdmin, Status: 204}
|
||||
)
|
||||
|
||||
var RoomRoutes = []RoomRoute{
|
||||
{Route: Route{Pattern: "GET /api/room", Auth: AuthTicket, Status: 200}, Type: RequestRoomSync},
|
||||
{Route: Route{Pattern: "POST /api/room/queue", Auth: AuthTicket, Status: 201, Location: true}, Type: RequestQueueAdd, body: true},
|
||||
{Route: Route{Pattern: "DELETE /api/room/queue/{entryId}", Auth: AuthTicket, Status: 204}, Type: RequestQueueRemove, params: []string{"entryId"}},
|
||||
{Route: Route{Pattern: "PATCH /api/room/queue/{entryId}", Auth: AuthTicket, Status: 204}, Type: RequestQueueMove, params: []string{"entryId"}, body: true},
|
||||
{Route: Route{Pattern: "GET /api/room/search/tracks", Auth: AuthTicket, Status: 200}, Type: RequestSearchTracks, query: map[string]queryKind{"keyword": queryString, "limit": queryInteger, "source": queryString}},
|
||||
}
|
||||
|
||||
var errorCodeHTTPStatus = map[ErrorCode]int{
|
||||
ErrorCodeInvalidArgument: 400,
|
||||
ErrorCodeNotFound: 404,
|
||||
}
|
||||
|
||||
// IDL 没有为某个取值指定 HTTP 状态码时返回 0。
|
||||
func (v ErrorCode) HTTPStatus() int {
|
||||
return errorCodeHTTPStatus[v]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
|
||||
|
||||
import type { ErrorInfo, Member, Room } from '../realtime/protocol.gen'
|
||||
|
||||
export interface AdminUpdateRoomRequest {
|
||||
homepage?: string
|
||||
limit?: number
|
||||
name?: string
|
||||
note?: string
|
||||
slug?: string
|
||||
}
|
||||
|
||||
export type CreateMemberRequest = Record<string, never>
|
||||
|
||||
export interface CreateMemberResponse {
|
||||
member: Member
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface CreateRoomRequest {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface CreateRoomResponse {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: ErrorInfo
|
||||
}
|
||||
|
||||
export type Auth = 'none' | 'member' | 'admin'
|
||||
|
||||
export interface HttpRequest {
|
||||
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
path: string
|
||||
auth: Auth
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
export type HttpSend = <T>(request: HttpRequest) => Promise<T>
|
||||
|
||||
export function httpClient(send: HttpSend) {
|
||||
return {
|
||||
createMember: () => send<CreateMemberResponse>({ method: 'POST', path: '/api/members', auth: 'none', body: {} }),
|
||||
getRoom: (ref: string) => send<Room>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}`, auth: 'none' }),
|
||||
createRoom: (body: CreateRoomRequest) => send<CreateRoomResponse>({ method: 'POST', path: '/api/rooms', auth: 'none', body }),
|
||||
getSeat: (ref: string, seat: string) => send<Member>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}/seats/${encodeURIComponent(seat)}`, auth: 'member' }),
|
||||
adminUpdateRoom: (id: string, body: AdminUpdateRoomRequest) => send<void>({ method: 'PATCH', path: `/api/admin/rooms/${encodeURIComponent(id)}`, auth: 'admin', body }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isWebsite } from './protocol.gen'
|
||||
|
||||
function cases(name: string): { value: string, valid: boolean }[] {
|
||||
return JSON.parse(readFileSync(new URL(`../../../../protocol/testdata/${name}.json`, import.meta.url), 'utf8')) as { value: string, valid: boolean }[]
|
||||
}
|
||||
|
||||
describe('isWebsite', () => {
|
||||
it('对 protocol/testdata/Website.json 中的每个用例与服务端的判断相同', () => {
|
||||
expect(cases('Website').filter(c => isWebsite(c.value) !== c.valid).map(c => c.value)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
|
||||
|
||||
export const PROTOCOL_VERSION = '362d144af81ba8bf'
|
||||
|
||||
export type Empty = Record<string, never>
|
||||
|
||||
export type ErrorCode = 'INVALID_ARGUMENT' | 'NOT_FOUND' | 'CLIENT_OUTDATED'
|
||||
|
||||
export interface ErrorInfo {
|
||||
code: ErrorCode
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface HelloRequest {
|
||||
clientTimeMs: number
|
||||
roomId: string
|
||||
}
|
||||
|
||||
export interface HelloResponse {
|
||||
owner: Member | null
|
||||
room: Room
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface MembersUpdatedEvent {
|
||||
members: Member[]
|
||||
note?: string
|
||||
}
|
||||
|
||||
export type QueueAddRequest = { track: Track, requester?: string } | { source: Source, keyword: string, requester?: string }
|
||||
|
||||
export interface QueueMoveRequest {
|
||||
entryId: string
|
||||
position: number
|
||||
}
|
||||
|
||||
export interface QueueRemoveRequest {
|
||||
entryId: string
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
gain: number
|
||||
id: string
|
||||
members: Member[]
|
||||
open: boolean
|
||||
slug: string | null
|
||||
}
|
||||
|
||||
export type RoomSyncRequest = Record<string, never>
|
||||
|
||||
export interface SearchTracksRequest {
|
||||
keyword: string
|
||||
limit?: number
|
||||
source: Source
|
||||
}
|
||||
|
||||
export interface SearchTracksResponse {
|
||||
total: number
|
||||
}
|
||||
|
||||
export type Source = 'netease' | 'qq' | 'link'
|
||||
|
||||
export interface Track {
|
||||
id: string
|
||||
source: Source
|
||||
}
|
||||
|
||||
export interface RequestDataMap {
|
||||
'hello': HelloRequest
|
||||
'room.sync': RoomSyncRequest
|
||||
'queue.add': QueueAddRequest
|
||||
'queue.remove': QueueRemoveRequest
|
||||
'queue.move': QueueMoveRequest
|
||||
'search.tracks': SearchTracksRequest
|
||||
}
|
||||
|
||||
export interface ResponseDataMap {
|
||||
'hello': HelloResponse
|
||||
'room.sync': Room
|
||||
'queue.add': Empty
|
||||
'queue.remove': Empty
|
||||
'queue.move': Empty
|
||||
'search.tracks': SearchTracksResponse
|
||||
}
|
||||
|
||||
export interface ConnectionEventDataMap {
|
||||
'error': ErrorInfo
|
||||
}
|
||||
|
||||
export interface RoomEventDataMap {
|
||||
'members.updated': MembersUpdatedEvent
|
||||
}
|
||||
|
||||
export type RequestType = keyof RequestDataMap
|
||||
export type ConnectionEventType = keyof ConnectionEventDataMap
|
||||
export type RoomEventType = keyof RoomEventDataMap
|
||||
|
||||
export interface RequestMessage<T extends RequestType = RequestType> {
|
||||
kind: 'request'
|
||||
type: T
|
||||
id: string
|
||||
data: RequestDataMap[T]
|
||||
}
|
||||
|
||||
export type ResponseMessage<T extends RequestType = RequestType> = {
|
||||
[K in T]: { kind: 'response', type: K, id: string } & (
|
||||
| { data: ResponseDataMap[K], error?: undefined }
|
||||
| { data: Empty, error: ErrorInfo }
|
||||
)
|
||||
}[T]
|
||||
|
||||
export type ConnectionEventMessage<T extends ConnectionEventType = ConnectionEventType> = {
|
||||
[K in T]: { kind: 'event', type: K, data: ConnectionEventDataMap[K] }
|
||||
}[T]
|
||||
|
||||
export type RoomEventMessage<T extends RoomEventType = RoomEventType> = {
|
||||
[K in T]: { kind: 'event', type: K, term: number, seq: number, data: RoomEventDataMap[K] }
|
||||
}[T]
|
||||
|
||||
export type ServerMessage = ResponseMessage | ConnectionEventMessage | RoomEventMessage
|
||||
|
||||
export const CloseCode = {
|
||||
invalidArgument: 1008,
|
||||
clientOutdated: 4426,
|
||||
} as const
|
||||
|
||||
export type Send = <T extends RequestType>(type: T, data: RequestDataMap[T]) => Promise<ResponseDataMap[T]>
|
||||
|
||||
export function roomRequests(send: Send) {
|
||||
return {
|
||||
roomSync: () => send('room.sync', {}),
|
||||
queueAdd: (data: QueueAddRequest) => send('queue.add', data),
|
||||
queueRemove: (data: QueueRemoveRequest) => send('queue.remove', data),
|
||||
queueMove: (data: QueueMoveRequest) => send('queue.move', data),
|
||||
searchTracks: (data: SearchTracksRequest) => send('search.tracks', data),
|
||||
}
|
||||
}
|
||||
|
||||
function validURL(value: string): boolean {
|
||||
if (/[\u0000-\u0020\u007F\\]/.test(value))
|
||||
return false
|
||||
const scheme = /^https?:\/\//i.exec(value)
|
||||
if (scheme === null)
|
||||
return false
|
||||
const rest = value.slice(scheme[0].length)
|
||||
const end = rest.search(/[/?#]/)
|
||||
const authority = end < 0 ? rest : rest.slice(0, end)
|
||||
const tail = end < 0 ? '' : rest.slice(end)
|
||||
let host = authority
|
||||
let port = ''
|
||||
if (authority.startsWith('[')) {
|
||||
const close = authority.indexOf(']') + 1
|
||||
host = authority.slice(0, close)
|
||||
if (close === 0 || !/^\[[0-9A-Fa-f:.]+\]$/.test(host))
|
||||
return false
|
||||
const after = authority.slice(close)
|
||||
if (after !== '') {
|
||||
if (!after.startsWith(':'))
|
||||
return false
|
||||
port = after.slice(1)
|
||||
}
|
||||
}
|
||||
else {
|
||||
const colon = authority.indexOf(':')
|
||||
if (colon >= 0) {
|
||||
host = authority.slice(0, colon)
|
||||
port = authority.slice(colon + 1)
|
||||
}
|
||||
if (!/^[\w.~\u0080-\u{10FFFF}-]+$/u.test(host))
|
||||
return false
|
||||
const labels = host.replace(/\.$/, '').split('.')
|
||||
if (/^(?:\d+|0x[0-9a-f]*)$/i.test(labels[labels.length - 1]) && !/^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/.test(host))
|
||||
return false
|
||||
}
|
||||
if (!/^\d*$/.test(port) || Number(port) > 65535)
|
||||
return false
|
||||
const hash = tail.indexOf('#')
|
||||
const path = (hash < 0 ? tail : tail.slice(0, hash)).split('?')[0]
|
||||
const fragment = hash < 0 ? '' : tail.slice(hash + 1)
|
||||
const badEscape = /%(?![0-9A-Fa-f]{2})/
|
||||
if (badEscape.test(path) || badEscape.test(fragment))
|
||||
return false
|
||||
try {
|
||||
return new URL(value).hostname !== ''
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isWebsite(value: string): boolean {
|
||||
return [...value].length <= 200 && new TextEncoder().encode(value).length <= 600 && validURL(value)
|
||||
}
|
||||
Reference in new issue
Block a user