Skip to content

Commit 397debf

Browse files
committed
Add Go custom schema validation
1 parent ff98068 commit 397debf

4 files changed

Lines changed: 299 additions & 17 deletions

File tree

client/go/ModCDPClient.go

Lines changed: 202 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import (
4646
"sync"
4747
"time"
4848

49+
abxjsonschema "github.com/ArchiveBox/abxbus/abxbus-go/jsonschema"
4950
"github.com/gobwas/ws"
5051
"github.com/gobwas/ws/wsutil"
5152
)
@@ -151,7 +152,7 @@ type CustomEvent struct {
151152

152153
type CustomCommand struct {
153154
Name string `json:"name"`
154-
Expression string `json:"expression"`
155+
Expression string `json:"expression,omitempty"`
155156
ParamsSchema map[string]any `json:"paramsSchema,omitempty"`
156157
ResultSchema map[string]any `json:"resultSchema,omitempty"`
157158
}
@@ -217,6 +218,10 @@ type ModCDPClient struct {
217218
pending map[int64]chan map[string]any
218219
handlers map[string][]Handler
219220
cdpHandlers map[string][]func(CDPEvent)
221+
commandParamsSchemas map[string]map[string]any
222+
commandResultSchemas map[string]map[string]any
223+
eventSchemas map[string]map[string]any
224+
schemaMu sync.RWMutex
220225
handlersMu sync.Mutex
221226
targetSessions map[string]string
222227
sessionTargets map[string]map[string]any
@@ -273,14 +278,19 @@ func New(opts Options) *ModCDPClient {
273278
if opts.WSConnectErrorSettleTimeoutMS == 0 {
274279
opts.WSConnectErrorSettleTimeoutMS = DefaultWSConnectErrorSettleTimeoutMS
275280
}
276-
return &ModCDPClient{
277-
opts: opts,
278-
pending: map[int64]chan map[string]any{},
279-
handlers: map[string][]Handler{},
280-
cdpHandlers: map[string][]func(CDPEvent){},
281-
targetSessions: map[string]string{},
282-
sessionTargets: map[string]map[string]any{},
283-
}
281+
client := &ModCDPClient{
282+
opts: opts,
283+
pending: map[int64]chan map[string]any{},
284+
handlers: map[string][]Handler{},
285+
cdpHandlers: map[string][]func(CDPEvent){},
286+
commandParamsSchemas: map[string]map[string]any{},
287+
commandResultSchemas: map[string]map[string]any{},
288+
eventSchemas: map[string]map[string]any{},
289+
targetSessions: map[string]string{},
290+
sessionTargets: map[string]map[string]any{},
291+
}
292+
client.hydrateCustomSurface()
293+
return client
284294
}
285295

286296
func (c *ModCDPClient) Connect() error {
@@ -436,11 +446,171 @@ func (c *ModCDPClient) Connect() error {
436446
return nil
437447
}
438448

449+
func normalizeModCDPName(name string) (string, error) {
450+
normalized := strings.TrimSpace(name)
451+
if normalized == "" {
452+
return "", fmt.Errorf("name must be a non-empty string")
453+
}
454+
if strings.Count(normalized, ".") != 1 {
455+
return "", fmt.Errorf("name must be in Domain.method form")
456+
}
457+
parts := strings.Split(normalized, ".")
458+
if parts[0] == "" || parts[1] == "" {
459+
return "", fmt.Errorf("name must be in Domain.method form")
460+
}
461+
return normalized, nil
462+
}
463+
464+
func cloneSchema(schema map[string]any) map[string]any {
465+
if schema == nil {
466+
return nil
467+
}
468+
normalized, _ := abxjsonschema.Normalize(schema).(map[string]any)
469+
if normalized == nil {
470+
return nil
471+
}
472+
return normalized
473+
}
474+
475+
func (c *ModCDPClient) hydrateCustomSurface() {
476+
c.schemaMu.Lock()
477+
defer c.schemaMu.Unlock()
478+
for _, command := range c.opts.CustomCommands {
479+
if command.Name == "" {
480+
continue
481+
}
482+
name, err := normalizeModCDPName(command.Name)
483+
if err != nil {
484+
continue
485+
}
486+
if schema := cloneSchema(command.ParamsSchema); schema != nil {
487+
c.commandParamsSchemas[name] = schema
488+
}
489+
if schema := cloneSchema(command.ResultSchema); schema != nil {
490+
c.commandResultSchemas[name] = schema
491+
}
492+
}
493+
for _, event := range c.opts.CustomEvents {
494+
if event.Name == "" {
495+
continue
496+
}
497+
name, err := normalizeModCDPName(event.Name)
498+
if err != nil {
499+
continue
500+
}
501+
if schema := cloneSchema(event.EventSchema); schema != nil {
502+
c.eventSchemas[name] = schema
503+
}
504+
}
505+
}
506+
507+
func (c *ModCDPClient) registerCustomCommandParams(params map[string]any) (string, bool, error) {
508+
rawName, _ := params["name"].(string)
509+
name, err := normalizeModCDPName(rawName)
510+
if err != nil {
511+
return "", false, err
512+
}
513+
c.schemaMu.Lock()
514+
defer c.schemaMu.Unlock()
515+
if rawSchema, ok := params["paramsSchema"].(map[string]any); ok {
516+
if schema := cloneSchema(rawSchema); schema != nil {
517+
c.commandParamsSchemas[name] = schema
518+
}
519+
}
520+
if rawSchema, ok := params["resultSchema"].(map[string]any); ok {
521+
if schema := cloneSchema(rawSchema); schema != nil {
522+
c.commandResultSchemas[name] = schema
523+
}
524+
}
525+
expression, _ := params["expression"].(string)
526+
return name, expression != "", nil
527+
}
528+
529+
func (c *ModCDPClient) registerCustomEventParams(params map[string]any) (string, error) {
530+
rawName, _ := params["name"].(string)
531+
name, err := normalizeModCDPName(rawName)
532+
if err != nil {
533+
return "", err
534+
}
535+
c.schemaMu.Lock()
536+
defer c.schemaMu.Unlock()
537+
if rawSchema, ok := params["eventSchema"].(map[string]any); ok {
538+
if schema := cloneSchema(rawSchema); schema != nil {
539+
c.eventSchemas[name] = schema
540+
}
541+
}
542+
return name, nil
543+
}
544+
545+
func (c *ModCDPClient) validateCommandParams(method string, params map[string]any) error {
546+
c.schemaMu.RLock()
547+
schema := c.commandParamsSchemas[method]
548+
c.schemaMu.RUnlock()
549+
if schema == nil {
550+
return nil
551+
}
552+
if err := abxjsonschema.Validate(schema, params); err != nil {
553+
return fmt.Errorf("%s params did not match paramsSchema: %w", method, err)
554+
}
555+
return nil
556+
}
557+
558+
func (c *ModCDPClient) validateCommandResult(method string, result any) error {
559+
c.schemaMu.RLock()
560+
schema := c.commandResultSchemas[method]
561+
c.schemaMu.RUnlock()
562+
if schema == nil {
563+
return nil
564+
}
565+
if err := abxjsonschema.Validate(schema, result); err != nil {
566+
return fmt.Errorf("%s result did not match resultSchema: %w", method, err)
567+
}
568+
return nil
569+
}
570+
571+
func (c *ModCDPClient) validateEventData(event string, data any) (any, bool) {
572+
c.schemaMu.RLock()
573+
schema := c.eventSchemas[event]
574+
c.schemaMu.RUnlock()
575+
if schema == nil {
576+
return data, true
577+
}
578+
if err := abxjsonschema.Validate(schema, data); err != nil {
579+
fmt.Fprintf(os.Stderr, "[ModCDPClient] %s event did not match eventSchema: %v\n", event, err)
580+
return nil, false
581+
}
582+
return data, true
583+
}
584+
439585
func (c *ModCDPClient) Send(method string, params map[string]any) (any, error) {
440586
startedAt := time.Now().UnixMilli()
441587
if params == nil {
442588
params = map[string]any{}
443589
}
590+
if method == "Mod.addCustomCommand" {
591+
name, hasExpression, err := c.registerCustomCommandParams(params)
592+
if err != nil {
593+
return nil, err
594+
}
595+
if !hasExpression {
596+
completedAt := time.Now().UnixMilli()
597+
c.LastCommandTiming = map[string]any{
598+
"method": method,
599+
"target": "client",
600+
"started_at": startedAt,
601+
"completed_at": completedAt,
602+
"duration_ms": completedAt - startedAt,
603+
}
604+
return map[string]any{"name": name, "registered": true}, nil
605+
}
606+
} else if method == "Mod.addCustomEvent" {
607+
if _, err := c.registerCustomEventParams(params); err != nil {
608+
return nil, err
609+
}
610+
}
611+
if err := c.validateCommandParams(method, params); err != nil {
612+
return nil, err
613+
}
444614
command, err := wrapCommandIfNeeded(method, params, c.opts.Routes, c.ExtSessionID)
445615
if err != nil {
446616
return nil, err
@@ -454,7 +624,13 @@ func (c *ModCDPClient) Send(method string, params map[string]any) (any, error) {
454624
"completed_at": completedAt,
455625
"duration_ms": completedAt - startedAt,
456626
}
457-
return result, err
627+
if err != nil {
628+
return nil, err
629+
}
630+
if err := c.validateCommandResult(method, result); err != nil {
631+
return nil, err
632+
}
633+
return result, nil
458634
}
459635

460636
func (c *ModCDPClient) SendRaw(method string, params map[string]any, sessionID ...string) (map[string]any, error) {
@@ -926,16 +1102,20 @@ func (c *ModCDPClient) reader() {
9261102
params, _ := msg["params"].(map[string]any)
9271103
bindingName, _ := params["name"].(string)
9281104
if event, data, ok := unwrapEventIfNeeded(method, params, sessionID, c.ExtSessionID); ok {
1105+
validatedData, valid := c.validateEventData(event, data)
1106+
if !valid {
1107+
continue
1108+
}
9291109
c.handlersMu.Lock()
9301110
hs := append([]Handler(nil), c.handlers[event]...)
9311111
cdpHandlers := append([]func(CDPEvent){}, c.cdpHandlers["*"]...)
9321112
cdpHandlers = append(cdpHandlers, c.cdpHandlers[event]...)
9331113
c.handlersMu.Unlock()
9341114
for _, h := range hs {
935-
go h(data)
1115+
go h(validatedData)
9361116
}
9371117
if bindingName == upstreamEventBindingName {
938-
dataMap, _ := data.(map[string]any)
1118+
dataMap, _ := validatedData.(map[string]any)
9391119
cdpEvent := CDPEvent{Method: event, Params: dataMap, CDPSessionID: sessionID, SessionID: sessionID}
9401120
for _, h := range cdpHandlers {
9411121
go h(cdpEvent)
@@ -945,16 +1125,24 @@ func (c *ModCDPClient) reader() {
9451125
continue
9461126
}
9471127
if method != "" {
1128+
validatedParams, valid := c.validateEventData(method, params)
1129+
if !valid {
1130+
continue
1131+
}
1132+
validatedParamsMap, _ := validatedParams.(map[string]any)
1133+
if validatedParamsMap == nil {
1134+
validatedParamsMap = map[string]any{}
1135+
}
9481136
c.handlersMu.Lock()
9491137
hs := append([]Handler(nil), c.handlers[method]...)
9501138
cdpHandlers := append([]func(CDPEvent){}, c.cdpHandlers["*"]...)
9511139
cdpHandlers = append(cdpHandlers, c.cdpHandlers[method]...)
9521140
c.handlersMu.Unlock()
9531141
for _, h := range hs {
954-
go h(params)
1142+
go h(validatedParams)
9551143
}
9561144
if len(cdpHandlers) > 0 {
957-
event := CDPEvent{Method: method, Params: params, CDPSessionID: sessionID, SessionID: sessionID}
1145+
event := CDPEvent{Method: method, Params: validatedParamsMap, CDPSessionID: sessionID, SessionID: sessionID}
9581146
for _, h := range cdpHandlers {
9591147
go h(event)
9601148
}

client/go/ModCDPClient_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package modcdp
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestCustomCommandSchemasValidateParamsAndResults(t *testing.T) {
9+
cdp := New(Options{
10+
CustomCommands: []CustomCommand{
11+
{
12+
Name: "Custom.echo",
13+
ParamsSchema: map[string]any{
14+
"type": "object",
15+
"required": []any{"value"},
16+
"properties": map[string]any{"value": map[string]any{"type": "string"}},
17+
"additionalProperties": false,
18+
},
19+
ResultSchema: map[string]any{
20+
"type": "object",
21+
"required": []any{"value"},
22+
"properties": map[string]any{"value": map[string]any{"type": "string"}},
23+
"additionalProperties": false,
24+
},
25+
},
26+
},
27+
})
28+
29+
if err := cdp.validateCommandParams("Custom.echo", map[string]any{"value": "ok"}); err != nil {
30+
t.Fatalf("expected valid params, got %v", err)
31+
}
32+
if err := cdp.validateCommandParams("Custom.echo", map[string]any{"value": 42}); err == nil || !strings.Contains(err.Error(), "paramsSchema") {
33+
t.Fatalf("expected params schema error, got %v", err)
34+
}
35+
if err := cdp.validateCommandResult("Custom.echo", map[string]any{"value": "ok"}); err != nil {
36+
t.Fatalf("expected valid result, got %v", err)
37+
}
38+
if err := cdp.validateCommandResult("Custom.echo", map[string]any{"value": 42}); err == nil || !strings.Contains(err.Error(), "resultSchema") {
39+
t.Fatalf("expected result schema error, got %v", err)
40+
}
41+
}
42+
43+
func TestSchemaOnlyAddCustomCommandRegistersWithoutConnection(t *testing.T) {
44+
cdp := New(Options{})
45+
result, err := cdp.Send("Mod.addCustomCommand", map[string]any{
46+
"name": "Custom.clientOnly",
47+
"paramsSchema": map[string]any{
48+
"type": "object",
49+
"required": []any{"tabId"},
50+
"properties": map[string]any{"tabId": map[string]any{"type": "integer"}},
51+
"additionalProperties": false,
52+
},
53+
})
54+
if err != nil {
55+
t.Fatal(err)
56+
}
57+
registration, ok := result.(map[string]any)
58+
if !ok || registration["name"] != "Custom.clientOnly" || registration["registered"] != true {
59+
t.Fatalf("unexpected schema-only registration result: %#v", result)
60+
}
61+
if err := cdp.validateCommandParams("Custom.clientOnly", map[string]any{"tabId": 1}); err != nil {
62+
t.Fatalf("expected registered schema to validate params, got %v", err)
63+
}
64+
if err := cdp.validateCommandParams("Custom.clientOnly", map[string]any{"tabId": "1"}); err == nil {
65+
t.Fatal("expected registered schema to reject wrong params")
66+
}
67+
}
68+
69+
func TestCustomEventSchemasValidatePayloads(t *testing.T) {
70+
cdp := New(Options{
71+
CustomEvents: []CustomEvent{
72+
{
73+
Name: "Custom.changed",
74+
EventSchema: map[string]any{
75+
"type": "object",
76+
"required": []any{"targetId"},
77+
"properties": map[string]any{"targetId": map[string]any{"type": "string"}},
78+
"additionalProperties": false,
79+
},
80+
},
81+
},
82+
})
83+
84+
if _, ok := cdp.validateEventData("Custom.changed", map[string]any{"targetId": "target-1"}); !ok {
85+
t.Fatal("expected valid event payload")
86+
}
87+
if _, ok := cdp.validateEventData("Custom.changed", map[string]any{"targetId": 1}); ok {
88+
t.Fatal("expected invalid event payload")
89+
}
90+
}

0 commit comments

Comments
 (0)