test: add automated tests and fixtures

This commit is contained in:
DiheChen committed 2026-10-30 01:33:34 +08:00
1 parent c4008505e6
commit 50039353ad
215 files changed
+34050

No files matched your search

+76
View File
@@ -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 "只有行尾不同"
}
+613
View File
@@ -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)
}
}
}
}
+94
View File
@@ -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)
}
}
+344
View File
@@ -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
View File
@@ -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"
}
}
}
}
+179
View File
@@ -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)
}