From 82e58a193d5e32971dda4a9ef269474714151c3f Mon Sep 17 00:00:00 2001 From: Jay Date: Sun, 2 Nov 2025 14:31:46 -0500 Subject: [PATCH] Wrote roots-ws golang implementation. --- LICENSE | 21 ++ README.md | 332 ++++++++++++++++++++ envelope/enclose.go | 121 ++++++++ envelope/enclose_test.go | 289 +++++++++++++++++ envelope/envelope.go | 55 ++++ envelope/envelope_test.go | 110 +++++++ envelope/find.go | 324 ++++++++++++++++++++ envelope/find_test.go | 630 ++++++++++++++++++++++++++++++++++++++ errors/errors.go | 33 ++ go.mod | 8 + go.sum | 10 + roots-ws.go | 18 ++ roots-ws_test.go | 21 ++ 13 files changed, 1972 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 envelope/enclose.go create mode 100644 envelope/enclose_test.go create mode 100644 envelope/envelope.go create mode 100644 envelope/envelope_test.go create mode 100644 envelope/find.go create mode 100644 envelope/find_test.go create mode 100644 errors/errors.go create mode 100644 go.sum create mode 100644 roots-ws.go create mode 100644 roots-ws_test.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ce8b424 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 nostr:npub10mtatsat7ph6rsq0w8u8npt8d86x4jfr2nqjnvld2439q6f8ugqq0x27hf + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..151ee50 --- /dev/null +++ b/README.md @@ -0,0 +1,332 @@ +# Go-Roots-WS - Nostr WebSocket Transport for Golang + +Source: https://git.wisehodl.dev/jay/go-roots-ws + +Mirror: https://github.com/wisehodl/go-roots-ws + +## What this library does + +`go-roots-ws` is a consensus-layer Nostr protocol websocket transport library for golang. It only provides primitives for working with Nostr protocol websocket connection states and messages: + +- Websocket Connection States +- Envelope Structure +- Message Validation +- Protocol Message Creation +- Protocol Message Parsing +- Standard Label Handling + +## What this library does not do + +`go-roots-ws` serves as a foundation for other libraries and applications to implement higher level transport abstractions on top of it, including: + +- Connection Management +- Event Loops +- Subscription Handling +- State Management +- Reconnection Logic + +## Installation + +1. Add `go-roots-ws` to your project: + +```bash +go get git.wisehodl.dev/jay/go-roots-ws +``` + +If the primary repository is unavailable, use the `replace` directive in your go.mod file to get the package from the github mirror: + +``` +replace git.wisehodl.dev/jay/go-roots-ws => github.com/wisehodl/go-roots-ws latest +``` + +2. Import the packages: + +```golang +import ( + "encoding/json" + "git.wisehodl.dev/jay/go-roots/events" + "git.wisehodl.dev/jay/go-roots/filters" + "git.wisehodl.dev/jay/go-roots-ws/envelope" + "git.wisehodl.dev/jay/go-roots-ws/errors" +) +``` + +3. Access functions with appropriate namespaces. + +## Usage Examples + +### Envelope Creation + +#### Create EVENT envelope + +```go +// Create an event using go-roots +event := events.Event{ + ID: "abc123", + PubKey: "def456", + Kind: 1, + Content: "Hello Nostr!", + CreatedAt: int(time.Now().Unix()), +} + +// Convert to JSON +eventJSON, err := json.Marshal(event) +if err != nil { + log.Fatal(err) +} + +// Create envelope +env := envelope.EncloseEvent(eventJSON) +// Result: ["EVENT",{"id":"abc123","pubkey":"def456","kind":1,"content":"Hello Nostr!","created_at":1636394097}] +``` + +#### Create subscription EVENT envelope + +```go +// Create an event using go-roots +event := events.Event{ + ID: "abc123", + PubKey: "def456", + Kind: 1, + Content: "Hello Nostr!", + CreatedAt: int(time.Now().Unix()), +} + +// Convert to JSON +eventJSON, err := json.Marshal(event) +if err != nil { + log.Fatal(err) +} + +// Create envelope with subscription ID +subID := "sub1" +env := envelope.EncloseSubscriptionEvent(subID, eventJSON) +// Result: ["EVENT","sub1",{"id":"abc123","pubkey":"def456","kind":1,"content":"Hello Nostr!","created_at":1636394097}] +``` + +#### Create REQ envelope + +```go +// Create filters using go-roots +since := int(time.Now().Add(-24 * time.Hour).Unix()) +limit := 50 + +filter1 := filters.Filter{ + Kinds: []int{1}, + Limit: &limit, + Since: &since, +} + +filter2 := filters.Filter{ + Authors: []string{"def456"}, +} + +// Marshal filters to JSON +filter1JSON, err := filters.MarshalJSON(filter1) +if err != nil { + log.Fatal(err) +} + +filter2JSON, err := filters.MarshalJSON(filter2) +if err != nil { + log.Fatal(err) +} + +// Create envelope +subID := "sub1" +filtersJSON := [][]byte{filter1JSON, filter2JSON} +env := envelope.EncloseReq(subID, filtersJSON) +// Result: ["REQ","sub1",{"kinds":[1],"limit":50,"since":1636307697},{"authors":["def456"]}] +``` + +#### Create other envelope types + +```go +// Create CLOSE envelope +env := envelope.EncloseClose("sub1") +// Result: ["CLOSE","sub1"] + +// Create EOSE envelope +env := envelope.EncloseEOSE("sub1") +// Result: ["EOSE","sub1"] + +// Create NOTICE envelope +env := envelope.EncloseNotice("This is a notice") +// Result: ["NOTICE","This is a notice"] + +// Create OK envelope +env := envelope.EncloseOK("abc123", true, "Event accepted") +// Result: ["OK","abc123",true,"Event accepted"] + +// Create AUTH challenge +env := envelope.EncloseAuthChallenge("random-challenge-string") +// Result: ["AUTH","random-challenge-string"] + +// Create AUTH response +// Create an event using go-roots +authEvent := events.Event{ + ID: "abc123", + PubKey: "def456", + Kind: 22242, + Content: "", + CreatedAt: int(time.Now().Unix()), +} + +// Convert to JSON +authEventJSON, err := json.Marshal(authEvent) +if err != nil { + log.Fatal(err) +} + +// Create envelope +env := envelope.EncloseAuthResponse(authEventJSON) +// Result: ["AUTH",{"id":"abc123","pubkey":"def456","kind":22242,"content":"","created_at":1636394097}] +``` + +--- + +### Envelope Parsing + +#### Extract label from envelope + +```go +env := []byte(`["EVENT",{"id":"abc123","pubkey":"def456","kind":1,"content":"Hello Nostr!"}]`) +label, err := envelope.GetLabel(env) +if err != nil { + log.Fatal(err) +} +// label: "EVENT" + +// Check if label is standard +isStandard := envelope.IsStandardLabel(label) +// isStandard: true +``` + +#### Extract event from EVENT envelope + +```go +env := []byte(`["EVENT",{"id":"abc123","pubkey":"def456","kind":1,"content":"Hello Nostr!"}]`) +eventJSON, err := envelope.FindEvent(env) +if err != nil { + log.Fatal(err) +} + +// Parse into go-roots Event +var event events.Event +err = json.Unmarshal(eventJSON, &event) +if err != nil { + log.Fatal(err) +} + +// Validate the event +if err := events.Validate(event); err != nil { + log.Printf("Invalid event: %v", err) +} + +// Now you can access event properties +fmt.Println(event.ID, event.Kind, event.Content) +``` + +#### Extract subscription event + +```go +env := []byte(`["EVENT","sub1",{"id":"abc123","pubkey":"def456","kind":1,"content":"Hello Nostr!"}]`) +subID, eventJSON, err := envelope.FindSubscriptionEvent(env) +if err != nil { + log.Fatal(err) +} + +// Parse into go-roots Event +var event events.Event +err = json.Unmarshal(eventJSON, &event) +if err != nil { + log.Fatal(err) +} + +fmt.Printf("Subscription: %s, Event ID: %s\n", subID, event.ID) +``` + +#### Extract subscription request + +```go +env := []byte(`["REQ","sub1",{"kinds":[1],"limit":50},{"authors":["def456"]}]`) +subID, filtersJSON, err := envelope.FindReq(env) +if err != nil { + log.Fatal(err) +} + +// Parse each filter +var parsedFilters []filters.Filter +for _, filterJSON := range filtersJSON { + var filter filters.Filter + err := filters.UnmarshalJSON(filterJSON, &filter) + if err != nil { + log.Fatal(err) + } + parsedFilters = append(parsedFilters, filter) +} + +// Now you can use the filter objects +for i, filter := range parsedFilters { + fmt.Printf("Filter %d: %+v\n", i, filter) +} +``` + +#### Extract other envelope types + +```go +// Extract OK response +env := []byte(`["OK","abc123",true,"Event accepted"]`) +eventID, status, message, err := envelope.FindOK(env) +// eventID: "abc123" +// status: true +// message: "Event accepted" + +// Extract EOSE message +env := []byte(`["EOSE","sub1"]`) +subID, err := envelope.FindEOSE(env) +// subID: "sub1" + +// Extract CLOSE message +env := []byte(`["CLOSE","sub1"]`) +subID, err := envelope.FindClose(env) +// subID: "sub1" + +// Extract CLOSED message +env := []byte(`["CLOSED","sub1","Subscription complete"]`) +subID, message, err := envelope.FindClosed(env) +// subID: "sub1" +// message: "Subscription complete" + +// Extract NOTICE message +env := []byte(`["NOTICE","This is a notice"]`) +message, err := envelope.FindNotice(env) +// message: "This is a notice" + +// Extract AUTH challenge +env := []byte(`["AUTH","random-challenge-string"]`) +challenge, err := envelope.FindAuthChallenge(env) +// challenge: "random-challenge-string" + +// Extract AUTH response +env := []byte(`["AUTH",{"id":"abc123","pubkey":"def456","kind":22242,"content":""}]`) +authEventJSON, err := envelope.FindAuthResponse(env) +if err != nil { + log.Fatal(err) +} + +// Parse into go-roots Event +var authEvent events.Event +err = json.Unmarshal(authEventJSON, &authEvent) +if err != nil { + log.Fatal(err) +} +``` + +## Testing + +This library contains a comprehensive suite of unit tests. Run them with: + +```bash +go test ./... +``` diff --git a/envelope/enclose.go b/envelope/enclose.go new file mode 100644 index 0000000..c79149b --- /dev/null +++ b/envelope/enclose.go @@ -0,0 +1,121 @@ +package envelope + +import ( + "bytes" + "strconv" +) + +// EncloseEvent creates an EVENT envelope for publishing events. +// It wraps the provided event JSON in the format ["EVENT", event]. +func EncloseEvent(event []byte) Envelope { + var buf bytes.Buffer + buf.WriteString(`["EVENT",`) + buf.Write(event) + buf.WriteByte(']') + return buf.Bytes() +} + +// EncloseOK creates an OK envelope acknowledging receipt of an event. +// Format: ["OK", eventID, status, message] +func EncloseOK(eventID string, status bool, message string) Envelope { + var buf bytes.Buffer + buf.WriteString(`["OK","`) + buf.WriteString(eventID) + buf.WriteString(`",`) + buf.WriteString(strconv.FormatBool(status)) + buf.WriteString(`,"`) + buf.WriteString(message) + buf.WriteString(`"]`) + return buf.Bytes() +} + +// EncloseReq creates a REQ envelope for subscription requests. +// Format: ["REQ", subID, filter1, filter2, ...] +func EncloseReq(subID string, filters [][]byte) Envelope { + var buf bytes.Buffer + buf.WriteString(`["REQ","`) + buf.WriteString(subID) + buf.WriteString(`"`) + + for _, filter := range filters { + buf.WriteString(`,`) + buf.Write(filter) + } + + buf.WriteByte(']') + return buf.Bytes() +} + +// EncloseSubscriptionEvent creates an EVENT envelope for delivering subscription events. +// Format: ["EVENT", subID, event] +func EncloseSubscriptionEvent(subID string, event []byte) Envelope { + var buf bytes.Buffer + buf.WriteString(`["EVENT","`) + buf.WriteString(subID) + buf.WriteString(`",`) + buf.Write(event) + buf.WriteByte(']') + return buf.Bytes() +} + +// EncloseEOSE creates an EOSE (End of Stored Events) envelope. +// Format: ["EOSE", subID] +func EncloseEOSE(subID string) Envelope { + var buf bytes.Buffer + buf.WriteString(`["EOSE","`) + buf.WriteString(subID) + buf.WriteString(`"]`) + return buf.Bytes() +} + +// EncloseClose creates a CLOSE envelope for ending a subscription. +// Format: ["CLOSE", subID] +func EncloseClose(subID string) Envelope { + var buf bytes.Buffer + buf.WriteString(`["CLOSE","`) + buf.WriteString(subID) + buf.WriteString(`"]`) + return buf.Bytes() +} + +// EncloseClosed creates a CLOSED envelope for indicating a terminated subscription. +// Format: ["CLOSED", subID, message] +func EncloseClosed(subID string, message string) Envelope { + var buf bytes.Buffer + buf.WriteString(`["CLOSED","`) + buf.WriteString(subID) + buf.WriteString(`","`) + buf.WriteString(message) + buf.WriteString(`"]`) + return buf.Bytes() +} + +// EncloseNotice creates a NOTICE envelope for responder messages. +// Format: ["NOTICE", message] +func EncloseNotice(message string) Envelope { + var buf bytes.Buffer + buf.WriteString(`["NOTICE","`) + buf.WriteString(message) + buf.WriteString(`"]`) + return buf.Bytes() +} + +// EncloseAuthChallenge creates an AUTH challenge envelope. +// Format: ["AUTH", challenge] +func EncloseAuthChallenge(challenge string) Envelope { + var buf bytes.Buffer + buf.WriteString(`["AUTH","`) + buf.WriteString(challenge) + buf.WriteString(`"]`) + return buf.Bytes() +} + +// EncloseAuthResponse creates an AUTH response envelope. +// Format: ["AUTH", event] +func EncloseAuthResponse(event []byte) Envelope { + var buf bytes.Buffer + buf.WriteString(`["AUTH",`) + buf.Write(event) + buf.WriteByte(']') + return buf.Bytes() +} diff --git a/envelope/enclose_test.go b/envelope/enclose_test.go new file mode 100644 index 0000000..8e70a3f --- /dev/null +++ b/envelope/enclose_test.go @@ -0,0 +1,289 @@ +package envelope + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestEncloseEvent(t *testing.T) { + cases := []struct { + name string + event []byte + want Envelope + }{ + { + name: "empty event", + event: []byte("{}"), + want: []byte(`["EVENT",{}]`), + }, + { + name: "invalid json", + event: []byte("in[valid,]"), + want: []byte(`["EVENT",in[valid,]]`), + }, + { + name: "populated event", + event: []byte(`{"id":"abc123","kind":1,"sig":"abc123"}`), + want: []byte(`["EVENT",{"id":"abc123","kind":1,"sig":"abc123"}]`), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := EncloseEvent(tc.event) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestEncloseOK(t *testing.T) { + cases := []struct { + name string + eventID string + status bool + message string + want Envelope + }{ + { + name: "successful event", + eventID: "abc123", + status: true, + message: "Event accepted", + want: []byte(`["OK","abc123",true,"Event accepted"]`), + }, + { + name: "rejected event", + eventID: "xyz789", + status: false, + message: "Invalid signature", + want: []byte(`["OK","xyz789",false,"Invalid signature"]`), + }, + { + name: "empty message", + eventID: "def456", + status: true, + message: "", + want: []byte(`["OK","def456",true,""]`), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := EncloseOK(tc.eventID, tc.status, tc.message) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestEncloseReq(t *testing.T) { + cases := []struct { + name string + subID string + filters [][]byte + want Envelope + }{ + { + name: "single filter", + subID: "sub1", + filters: [][]byte{[]byte(`{"kinds":[1],"limit":10}`)}, + want: []byte(`["REQ","sub1",{"kinds":[1],"limit":10}]`), + }, + { + name: "multiple filters", + subID: "sub2", + filters: [][]byte{[]byte(`{"kinds":[1]}`), []byte(`{"authors":["abc"]}`)}, + want: []byte(`["REQ","sub2",{"kinds":[1]},{"authors":["abc"]}]`), + }, + { + name: "no filters", + subID: "sub3", + filters: [][]byte{}, + want: []byte(`["REQ","sub3"]`), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := EncloseReq(tc.subID, tc.filters) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestEncloseSubscriptionEvent(t *testing.T) { + cases := []struct { + name string + subID string + event []byte + want Envelope + }{ + { + name: "basic event", + subID: "sub1", + event: []byte(`{"id":"abc123","kind":1}`), + want: []byte(`["EVENT","sub1",{"id":"abc123","kind":1}]`), + }, + { + name: "empty event", + subID: "sub2", + event: []byte(`{}`), + want: []byte(`["EVENT","sub2",{}]`), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := EncloseSubscriptionEvent(tc.subID, tc.event) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestEncloseEOSE(t *testing.T) { + cases := []struct { + name string + subID string + want Envelope + }{ + { + name: "valid subscription ID", + subID: "sub1", + want: []byte(`["EOSE","sub1"]`), + }, + { + name: "empty subscription ID", + subID: "", + want: []byte(`["EOSE",""]`), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := EncloseEOSE(tc.subID) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestEncloseClose(t *testing.T) { + cases := []struct { + name string + subID string + want Envelope + }{ + { + name: "valid subscription ID", + subID: "sub1", + want: []byte(`["CLOSE","sub1"]`), + }, + { + name: "empty subscription ID", + subID: "", + want: []byte(`["CLOSE",""]`), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := EncloseClose(tc.subID) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestEncloseClosed(t *testing.T) { + cases := []struct { + name string + subID string + message string + want Envelope + }{ + { + name: "with message", + subID: "sub1", + message: "Subscription complete", + want: []byte(`["CLOSED","sub1","Subscription complete"]`), + }, + { + name: "empty message", + subID: "sub2", + message: "", + want: []byte(`["CLOSED","sub2",""]`), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := EncloseClosed(tc.subID, tc.message) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestEncloseNotice(t *testing.T) { + cases := []struct { + name string + message string + want Envelope + }{ + { + name: "valid message", + message: "This is a notice", + want: []byte(`["NOTICE","This is a notice"]`), + }, + { + name: "empty message", + message: "", + want: []byte(`["NOTICE",""]`), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := EncloseNotice(tc.message) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestEncloseAuthChallenge(t *testing.T) { + cases := []struct { + name string + challenge string + want Envelope + }{ + { + name: "valid challenge", + challenge: "random-challenge-string", + want: []byte(`["AUTH","random-challenge-string"]`), + }, + { + name: "empty challenge", + challenge: "", + want: []byte(`["AUTH",""]`), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := EncloseAuthChallenge(tc.challenge) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestEncloseAuthResponse(t *testing.T) { + cases := []struct { + name string + event []byte + want Envelope + }{ + { + name: "valid event", + event: []byte(`{"id":"abc123","kind":22242}`), + want: []byte(`["AUTH",{"id":"abc123","kind":22242}]`), + }, + { + name: "empty event", + event: []byte(`{}`), + want: []byte(`["AUTH",{}]`), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := EncloseAuthResponse(tc.event) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/envelope/envelope.go b/envelope/envelope.go new file mode 100644 index 0000000..3da3ed2 --- /dev/null +++ b/envelope/envelope.go @@ -0,0 +1,55 @@ +// Package envelope provides types and functions for working with Nostr protocol +// websocket messages. It defines the Envelope type representing a Nostr message +// and offers utilities for creating, parsing, and validating standardized message +// formats. +package envelope + +import ( + "encoding/json" + "fmt" + "git.wisehodl.dev/jay/go-roots-ws/errors" +) + +// Envelope represents a Nostr websocket message. +type Envelope []byte + +// GetLabel extracts the message label from an envelope. +// Returns the label as a string or an error if the envelope is malformed. +func GetLabel(env Envelope) (string, error) { + var arr []json.RawMessage + if err := json.Unmarshal(env, &arr); err != nil { + return "", fmt.Errorf("%w: %v", errors.InvalidJSON, err) + } + + if len(arr) < 1 { + return "", fmt.Errorf("%w: empty envelope", errors.InvalidEnvelope) + } + + var label string + if err := json.Unmarshal(arr[0], &label); err != nil { + return "", fmt.Errorf("%w: label is not a string", errors.WrongFieldType) + } + + return label, nil +} + +// GetStandardLabels returns a set of standard Nostr websocket message labels +func GetStandardLabels() map[string]struct{} { + return map[string]struct{}{ + "EVENT": {}, + "REQ": {}, + "CLOSE": {}, + "CLOSED": {}, + "EOSE": {}, + "NOTICE": {}, + "OK": {}, + "AUTH": {}, + } +} + +// IsStandardLabel checks if the given label is a standard Nostr websocket message label +func IsStandardLabel(label string) bool { + labels := GetStandardLabels() + _, ok := labels[label] + return ok +} diff --git a/envelope/envelope_test.go b/envelope/envelope_test.go new file mode 100644 index 0000000..2e52c56 --- /dev/null +++ b/envelope/envelope_test.go @@ -0,0 +1,110 @@ +package envelope + +import ( + "git.wisehodl.dev/jay/go-roots-ws/errors" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestGetLabel(t *testing.T) { + cases := []struct { + name string + env Envelope + wantLabel string + wantErr error + wantErrText string + }{ + { + name: "valid envelope with EVENT label", + env: []byte(`["EVENT",{"id":"abc123"}]`), + wantLabel: "EVENT", + }, + { + name: "valid envelope with custom label", + env: []byte(`["TEST",{"data":"value"}]`), + wantLabel: "TEST", + }, + { + name: "invalid json", + env: []byte(`invalid`), + wantErr: errors.InvalidJSON, + }, + { + name: "empty array", + env: []byte(`[]`), + wantErr: errors.InvalidEnvelope, + wantErrText: "empty envelope", + }, + { + name: "label not a string", + env: []byte(`[123,{"id":"abc123"}]`), + wantErr: errors.WrongFieldType, + wantErrText: "label is not a string", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := GetLabel(tc.env) + + if tc.wantErr != nil || tc.wantErrText != "" { + if tc.wantErr != nil { + assert.ErrorIs(t, err, tc.wantErr) + } + if tc.wantErrText != "" { + assert.ErrorContains(t, err, tc.wantErrText) + } + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.wantLabel, got) + }) + } +} + +func TestGetStandardLabels(t *testing.T) { + expected := map[string]struct{}{ + "EVENT": {}, + "REQ": {}, + "CLOSE": {}, + "CLOSED": {}, + "EOSE": {}, + "NOTICE": {}, + "OK": {}, + "AUTH": {}, + } + + labels := GetStandardLabels() + + // Check that we have the exact same number of labels + assert.Equal(t, len(expected), len(labels)) + + // Check that all expected labels are present + for label := range expected { + _, exists := labels[label] + assert.True(t, exists, "Expected standard label %s not found", label) + } +} + +func TestIsStandardLabel(t *testing.T) { + standardCases := []string{ + "EVENT", "REQ", "CLOSE", "CLOSED", "EOSE", "NOTICE", "OK", "AUTH", + } + + nonStandardCases := []string{ + "TEST", "CUSTOM", "event", "REQ1", "", + } + + for _, label := range standardCases { + t.Run(label, func(t *testing.T) { + assert.True(t, IsStandardLabel(label), "Label %s should be standard", label) + }) + } + + for _, label := range nonStandardCases { + t.Run(label, func(t *testing.T) { + assert.False(t, IsStandardLabel(label), "Label %s should not be standard", label) + }) + } +} diff --git a/envelope/find.go b/envelope/find.go new file mode 100644 index 0000000..2333383 --- /dev/null +++ b/envelope/find.go @@ -0,0 +1,324 @@ +package envelope + +import ( + "encoding/json" + "fmt" + "git.wisehodl.dev/jay/go-roots-ws/errors" +) + +// CheckArrayLength is a helper function that ensures the JSON array has at +// least the minimum length required +func CheckArrayLength(arr []json.RawMessage, minLen int) error { + if len(arr) < minLen { + return fmt.Errorf("%w: expected %d elements, got %d", errors.InvalidEnvelope, minLen, len(arr)) + } + return nil +} + +// CheckLabel is a helper function that verifies that the envelope label +// matches the expected one +func CheckLabel(got, want string) error { + if got != want { + return fmt.Errorf("%w: expected %s, got %s", errors.WrongEnvelopeLabel, want, got) + } + return nil +} + +// ParseElement is a helper function that unmarshals an array element into the +// provided value +func ParseElement(element json.RawMessage, value interface{}, position string) error { + if err := json.Unmarshal(element, value); err != nil { + return fmt.Errorf("%w: %s is not the expected type", errors.WrongFieldType, position) + } + return nil +} + +// FindEvent extracts an event from an EVENT envelope with no subscription ID. +// Expected Format: ["EVENT", event] +func FindEvent(env Envelope) ([]byte, error) { + var arr []json.RawMessage + if err := json.Unmarshal(env, &arr); err != nil { + return nil, fmt.Errorf("%w: %v", errors.InvalidJSON, err) + } + + if err := CheckArrayLength(arr, 2); err != nil { + return nil, err + } + + var label string + if err := ParseElement(arr[0], &label, "envelope label"); err != nil { + return nil, err + } + + if err := CheckLabel(label, "EVENT"); err != nil { + return nil, err + } + + return arr[1], nil +} + +// FindSubscriptionEvent extracts an event and subscription ID from an EVENT envelope. +// Expected Format: ["EVENT", subID, event] +func FindSubscriptionEvent(env Envelope) (subID string, event []byte, err error) { + var arr []json.RawMessage + if err = json.Unmarshal(env, &arr); err != nil { + return "", nil, fmt.Errorf("%w: %v", errors.InvalidJSON, err) + } + + if err = CheckArrayLength(arr, 3); err != nil { + return "", nil, err + } + + var label string + if err := ParseElement(arr[0], &label, "envelope label"); err != nil { + return "", nil, err + } + + if err = CheckLabel(label, "EVENT"); err != nil { + return "", nil, err + } + + if err = ParseElement(arr[1], &subID, "subscription ID"); err != nil { + return "", nil, err + } + + return subID, arr[2], nil +} + +// FindOK extracts eventID, status, and message from an OK envelope. +// Expected Format: ["OK", eventID, status, message] +func FindOK(env Envelope) (eventID string, status bool, message string, err error) { + var arr []json.RawMessage + if err = json.Unmarshal(env, &arr); err != nil { + return "", false, "", fmt.Errorf("%w: %v", errors.InvalidJSON, err) + } + + if err = CheckArrayLength(arr, 4); err != nil { + return "", false, "", err + } + + var label string + if err := ParseElement(arr[0], &label, "envelope label"); err != nil { + return "", false, "", err + } + + if err = CheckLabel(label, "OK"); err != nil { + return "", false, "", err + } + + if err = ParseElement(arr[1], &eventID, "event ID"); err != nil { + return "", false, "", err + } + + if err = ParseElement(arr[2], &status, "status"); err != nil { + return "", false, "", err + } + + if err = ParseElement(arr[3], &message, "message"); err != nil { + return "", false, "", err + } + + return eventID, status, message, nil +} + +// FindReq extracts subscription ID and filters from a REQ envelope. +// Expected Format: ["REQ", subID, filter1, filter2, ...] +func FindReq(env Envelope) (subID string, filters [][]byte, err error) { + var arr []json.RawMessage + if err = json.Unmarshal(env, &arr); err != nil { + return "", nil, fmt.Errorf("%w: %v", errors.InvalidJSON, err) + } + + if err = CheckArrayLength(arr, 2); err != nil { + return "", nil, err + } + + var label string + if err := ParseElement(arr[0], &label, "envelope label"); err != nil { + return "", nil, err + } + + if err = CheckLabel(label, "REQ"); err != nil { + return "", nil, err + } + + if err = ParseElement(arr[1], &subID, "subscription ID"); err != nil { + return "", nil, err + } + + filters = make([][]byte, 0, len(arr)-2) + for i := 2; i < len(arr); i++ { + filters = append(filters, arr[i]) + } + + return subID, filters, nil +} + +// FindEOSE extracts subscription ID from an EOSE envelope. +// Expected Format: ["EOSE", subID] +func FindEOSE(env Envelope) (subID string, err error) { + var arr []json.RawMessage + if err = json.Unmarshal(env, &arr); err != nil { + return "", fmt.Errorf("%w: %v", errors.InvalidJSON, err) + } + + if err = CheckArrayLength(arr, 2); err != nil { + return "", err + } + + var label string + if err := ParseElement(arr[0], &label, "envelope label"); err != nil { + return "", err + } + + if err = CheckLabel(label, "EOSE"); err != nil { + return "", err + } + + if err = ParseElement(arr[1], &subID, "subscription ID"); err != nil { + return "", err + } + + return subID, nil +} + +// FindClose extracts subscription ID from a CLOSE envelope. +// Expected Format: ["CLOSE", subID] +func FindClose(env Envelope) (subID string, err error) { + var arr []json.RawMessage + if err = json.Unmarshal(env, &arr); err != nil { + return "", fmt.Errorf("%w: %v", errors.InvalidJSON, err) + } + + if err = CheckArrayLength(arr, 2); err != nil { + return "", err + } + + var label string + if err := ParseElement(arr[0], &label, "envelope label"); err != nil { + return "", err + } + + if err = CheckLabel(label, "CLOSE"); err != nil { + return "", err + } + + if err = ParseElement(arr[1], &subID, "subscription ID"); err != nil { + return "", err + } + + return subID, nil +} + +// FindClosed extracts subscription ID and message from a CLOSED envelope. +// Expected Format: ["CLOSED", subID, message] +func FindClosed(env Envelope) (subID string, message string, err error) { + var arr []json.RawMessage + if err = json.Unmarshal(env, &arr); err != nil { + return "", "", fmt.Errorf("%w: %v", errors.InvalidJSON, err) + } + + if err = CheckArrayLength(arr, 3); err != nil { + return "", "", err + } + + var label string + if err := ParseElement(arr[0], &label, "envelope label"); err != nil { + return "", "", err + } + + if err = CheckLabel(label, "CLOSED"); err != nil { + return "", "", err + } + + if err = ParseElement(arr[1], &subID, "subscription ID"); err != nil { + return "", "", err + } + + if err = ParseElement(arr[2], &message, "message"); err != nil { + return "", "", err + } + + return subID, message, nil +} + +// FindNotice extracts message from a NOTICE envelope. +// Expected Format: ["NOTICE", message] +func FindNotice(env Envelope) (message string, err error) { + var arr []json.RawMessage + if err = json.Unmarshal(env, &arr); err != nil { + return "", fmt.Errorf("%w: %v", errors.InvalidJSON, err) + } + + if err = CheckArrayLength(arr, 2); err != nil { + return "", err + } + + var label string + if err := ParseElement(arr[0], &label, "envelope label"); err != nil { + return "", err + } + + if err = CheckLabel(label, "NOTICE"); err != nil { + return "", err + } + + if err = ParseElement(arr[1], &message, "message"); err != nil { + return "", err + } + + return message, nil +} + +// FindAuthChallenge extracts challenge from an AUTH challenge envelope. +// Expected Format: ["AUTH", challenge] +func FindAuthChallenge(env Envelope) (challenge string, err error) { + var arr []json.RawMessage + if err = json.Unmarshal(env, &arr); err != nil { + return "", fmt.Errorf("%w: %v", errors.InvalidJSON, err) + } + + if err = CheckArrayLength(arr, 2); err != nil { + return "", err + } + + var label string + if err := ParseElement(arr[0], &label, "envelope label"); err != nil { + return "", err + } + + if err = CheckLabel(label, "AUTH"); err != nil { + return "", err + } + + // Check if the second element is a string (AUTH challenge) + if err = ParseElement(arr[1], &challenge, "challenge"); err != nil { + return "", err + } + + return challenge, nil +} + +// FindAuthResponse extracts event from an AUTH response envelope. +// Expected Format: ["AUTH", event] +func FindAuthResponse(env Envelope) (event []byte, err error) { + var arr []json.RawMessage + if err = json.Unmarshal(env, &arr); err != nil { + return nil, fmt.Errorf("%w: %v", errors.InvalidJSON, err) + } + + if err = CheckArrayLength(arr, 2); err != nil { + return nil, err + } + + var label string + if err := ParseElement(arr[0], &label, "envelope label"); err != nil { + return nil, err + } + + if err = CheckLabel(label, "AUTH"); err != nil { + return nil, err + } + + return arr[1], nil +} diff --git a/envelope/find_test.go b/envelope/find_test.go new file mode 100644 index 0000000..2160bd3 --- /dev/null +++ b/envelope/find_test.go @@ -0,0 +1,630 @@ +package envelope + +import ( + "testing" + + "git.wisehodl.dev/jay/go-roots-ws/errors" + "github.com/stretchr/testify/assert" +) + +func TestFindEvent(t *testing.T) { + cases := []struct { + name string + env Envelope + wantEvent []byte + wantErr error + wantErrText string + }{ + { + name: "valid event", + env: []byte(`["EVENT",{"id":"abc123","kind":1}]`), + wantEvent: []byte(`{"id":"abc123","kind":1}`), + }, + { + name: "wrong label", + env: []byte(`["REQ",{"id":"abc123","kind":1}]`), + wantErr: errors.WrongEnvelopeLabel, + wantErrText: "expected EVENT, got REQ", + }, + { + name: "invalid json", + env: []byte(`invalid`), + wantErr: errors.InvalidJSON, + }, + { + name: "missing elements", + env: []byte(`["EVENT"]`), + wantErr: errors.InvalidEnvelope, + wantErrText: "expected 2 elements, got 1", + }, + { + name: "extraneous elements", + env: []byte(`["EVENT",{"id":"abc123"},"extra"]`), + wantEvent: []byte(`{"id":"abc123"}`), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := FindEvent(tc.env) + + if tc.wantErr != nil || tc.wantErrText != "" { + if tc.wantErr != nil { + assert.ErrorIs(t, err, tc.wantErr) + } + + if tc.wantErrText != "" { + assert.ErrorContains(t, err, tc.wantErrText) + } + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.wantEvent, got) + }) + } +} + +func TestFindSubscriptionEvent(t *testing.T) { + cases := []struct { + name string + env Envelope + wantSubID string + wantEvent []byte + wantErr error + wantErrText string + }{ + { + name: "valid event", + env: []byte(`["EVENT","sub1",{"id":"abc123","kind":1}]`), + wantSubID: "sub1", + wantEvent: []byte(`{"id":"abc123","kind":1}`), + }, + { + name: "wrong label", + env: []byte(`["REQ","sub1",{"id":"abc123","kind":1}]`), + wantErr: errors.WrongEnvelopeLabel, + wantErrText: "expected EVENT, got REQ", + }, + { + name: "invalid json", + env: []byte(`invalid`), + wantErr: errors.InvalidJSON, + }, + { + name: "missing elements", + env: []byte(`["EVENT","sub1"]`), + wantErr: errors.InvalidEnvelope, + wantErrText: "expected 3 elements, got 2", + }, + { + name: "extraneous elements", + env: []byte(`["EVENT","sub1",{"id":"abc123"},"extra"]`), + wantSubID: "sub1", + wantEvent: []byte(`{"id":"abc123"}`), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotSubID, gotEvent, err := FindSubscriptionEvent(tc.env) + + if tc.wantErr != nil || tc.wantErrText != "" { + if tc.wantErr != nil { + assert.ErrorIs(t, err, tc.wantErr) + } + + if tc.wantErrText != "" { + assert.ErrorContains(t, err, tc.wantErrText) + } + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.wantSubID, gotSubID) + assert.Equal(t, tc.wantEvent, gotEvent) + }) + } +} + +func TestFindOK(t *testing.T) { + cases := []struct { + name string + env Envelope + wantEventID string + wantStatus bool + wantMessage string + wantErr error + wantErrText string + }{ + { + name: "accepted event", + env: []byte(`["OK","abc123",true,"Event accepted"]`), + wantEventID: "abc123", + wantStatus: true, + wantMessage: "Event accepted", + }, + { + name: "rejected event", + env: []byte(`["OK","xyz789",false,"Invalid signature"]`), + wantEventID: "xyz789", + wantStatus: false, + wantMessage: "Invalid signature", + }, + { + name: "wrong status type", + env: []byte(`["OK","abc123","ok","Event accepted"]`), + wantErr: errors.WrongFieldType, + wantErrText: "status is not the expected type", + }, + { + name: "wrong label", + env: []byte(`["EVENT","abc123",true,"Event accepted"]`), + wantErr: errors.WrongEnvelopeLabel, + wantErrText: "expected OK, got EVENT", + }, + { + name: "invalid json", + env: []byte(`invalid`), + wantErr: errors.InvalidJSON, + }, + { + name: "missing elements", + env: []byte(`["OK","abc123",true]`), + wantErr: errors.InvalidEnvelope, + wantErrText: "expected 4 elements, got 3", + }, + { + name: "extraneous elements", + env: []byte(`["OK","abc123",true,"Event accepted","extra"]`), + wantEventID: "abc123", + wantStatus: true, + wantMessage: "Event accepted", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotEventID, gotStatus, gotMessage, err := FindOK(tc.env) + + if tc.wantErr != nil || tc.wantErrText != "" { + if tc.wantErr != nil { + assert.ErrorIs(t, err, tc.wantErr) + } + + if tc.wantErrText != "" { + assert.ErrorContains(t, err, tc.wantErrText) + } + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.wantEventID, gotEventID) + assert.Equal(t, tc.wantStatus, gotStatus) + assert.Equal(t, tc.wantMessage, gotMessage) + }) + } +} + +func TestFindReq(t *testing.T) { + cases := []struct { + name string + env Envelope + wantSubID string + wantFilters [][]byte + wantErr error + wantErrText string + }{ + { + name: "single filter", + env: []byte(`["REQ","sub1",{"kinds":[1],"limit":10}]`), + wantSubID: "sub1", + wantFilters: [][]byte{[]byte(`{"kinds":[1],"limit":10}`)}, + }, + { + name: "multiple filters", + env: []byte(`["REQ","sub2",{"kinds":[1]},{"authors":["abc"]}]`), + wantSubID: "sub2", + wantFilters: [][]byte{ + []byte(`{"kinds":[1]}`), + []byte(`{"authors":["abc"]}`), + }, + }, + { + name: "no filters", + env: []byte(`["REQ","sub3"]`), + wantSubID: "sub3", + wantFilters: [][]byte{}, + }, + { + name: "wrong label", + env: []byte(`["EVENT","sub1",{"kinds":[1],"limit":10}]`), + wantErr: errors.WrongEnvelopeLabel, + wantErrText: "expected REQ, got EVENT", + }, + { + name: "invalid json", + env: []byte(`invalid`), + wantErr: errors.InvalidJSON, + }, + { + name: "missing elements", + env: []byte(`["REQ"]`), + wantErr: errors.InvalidEnvelope, + wantErrText: "expected 2 elements, got 1", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotSubID, gotFilters, err := FindReq(tc.env) + + if tc.wantErr != nil || tc.wantErrText != "" { + if tc.wantErr != nil { + assert.ErrorIs(t, err, tc.wantErr) + } + + if tc.wantErrText != "" { + assert.ErrorContains(t, err, tc.wantErrText) + } + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.wantSubID, gotSubID) + assert.Equal(t, tc.wantFilters, gotFilters) + }) + } +} + +func TestFindEOSE(t *testing.T) { + cases := []struct { + name string + env Envelope + wantSubID string + wantErr error + wantErrText string + }{ + { + name: "valid EOSE", + env: []byte(`["EOSE","sub1"]`), + wantSubID: "sub1", + }, + { + name: "wrong label", + env: []byte(`["EVENT","sub1"]`), + wantErr: errors.WrongEnvelopeLabel, + wantErrText: "expected EOSE, got EVENT", + }, + { + name: "invalid json", + env: []byte(`invalid`), + wantErr: errors.InvalidJSON, + }, + { + name: "missing elements", + env: []byte(`["EOSE"]`), + wantErr: errors.InvalidEnvelope, + wantErrText: "expected 2 elements, got 1", + }, + { + name: "extraneous elements", + env: []byte(`["EOSE","sub1","extra"]`), + wantSubID: "sub1", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotSubID, err := FindEOSE(tc.env) + + if tc.wantErr != nil || tc.wantErrText != "" { + if tc.wantErr != nil { + assert.ErrorIs(t, err, tc.wantErr) + } + + if tc.wantErrText != "" { + assert.ErrorContains(t, err, tc.wantErrText) + } + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.wantSubID, gotSubID) + }) + } +} + +func TestFindClose(t *testing.T) { + cases := []struct { + name string + env Envelope + wantSubID string + wantErr error + wantErrText string + }{ + { + name: "valid CLOSE", + env: []byte(`["CLOSE","sub1"]`), + wantSubID: "sub1", + }, + { + name: "wrong label", + env: []byte(`["EVENT","sub1"]`), + wantErr: errors.WrongEnvelopeLabel, + wantErrText: "expected CLOSE, got EVENT", + }, + { + name: "invalid json", + env: []byte(`invalid`), + wantErr: errors.InvalidJSON, + }, + { + name: "missing elements", + env: []byte(`["CLOSE"]`), + wantErr: errors.InvalidEnvelope, + wantErrText: "expected 2 elements, got 1", + }, + { + name: "extraneous elements", + env: []byte(`["CLOSE","sub1","extra"]`), + wantSubID: "sub1", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotSubID, err := FindClose(tc.env) + + if tc.wantErr != nil || tc.wantErrText != "" { + if tc.wantErr != nil { + assert.ErrorIs(t, err, tc.wantErr) + } + + if tc.wantErrText != "" { + assert.ErrorContains(t, err, tc.wantErrText) + } + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.wantSubID, gotSubID) + }) + } +} + +func TestFindClosed(t *testing.T) { + cases := []struct { + name string + env Envelope + wantSubID string + wantMessage string + wantErr error + wantErrText string + }{ + { + name: "valid CLOSED", + env: []byte(`["CLOSED","sub1","Subscription complete"]`), + wantSubID: "sub1", + wantMessage: "Subscription complete", + }, + { + name: "wrong label", + env: []byte(`["EVENT","sub1","Subscription complete"]`), + wantErr: errors.WrongEnvelopeLabel, + wantErrText: "expected CLOSED, got EVENT", + }, + { + name: "invalid json", + env: []byte(`invalid`), + wantErr: errors.InvalidJSON, + }, + { + name: "missing elements", + env: []byte(`["CLOSED","sub1"]`), + wantErr: errors.InvalidEnvelope, + wantErrText: "expected 3 elements, got 2", + }, + { + name: "extraneous elements", + env: []byte(`["CLOSED","sub1","Subscription complete","extra"]`), + wantSubID: "sub1", + wantMessage: "Subscription complete", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotSubID, gotMessage, err := FindClosed(tc.env) + + if tc.wantErr != nil || tc.wantErrText != "" { + if tc.wantErr != nil { + assert.ErrorIs(t, err, tc.wantErr) + } + + if tc.wantErrText != "" { + assert.ErrorContains(t, err, tc.wantErrText) + } + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.wantSubID, gotSubID) + assert.Equal(t, tc.wantMessage, gotMessage) + }) + } +} + +func TestFindNotice(t *testing.T) { + cases := []struct { + name string + env Envelope + wantMessage string + wantErr error + wantErrText string + }{ + { + name: "valid NOTICE", + env: []byte(`["NOTICE","This is a notice"]`), + wantMessage: "This is a notice", + }, + { + name: "wrong label", + env: []byte(`["EVENT","This is a notice"]`), + wantErr: errors.WrongEnvelopeLabel, + wantErrText: "expected NOTICE, got EVENT", + }, + { + name: "invalid json", + env: []byte(`invalid`), + wantErr: errors.InvalidJSON, + }, + { + name: "missing elements", + env: []byte(`["NOTICE"]`), + wantErr: errors.InvalidEnvelope, + wantErrText: "expected 2 elements, got 1", + }, + { + name: "extraneous elements", + env: []byte(`["NOTICE","This is a notice","extra"]`), + wantMessage: "This is a notice", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotMessage, err := FindNotice(tc.env) + + if tc.wantErr != nil || tc.wantErrText != "" { + if tc.wantErr != nil { + assert.ErrorIs(t, err, tc.wantErr) + } + + if tc.wantErrText != "" { + assert.ErrorContains(t, err, tc.wantErrText) + } + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.wantMessage, gotMessage) + }) + } +} + +func TestFindAuthChallenge(t *testing.T) { + cases := []struct { + name string + env Envelope + wantChallenge string + wantErr error + wantErrText string + }{ + { + name: "valid AUTH challenge", + env: []byte(`["AUTH","random-challenge-string"]`), + wantChallenge: "random-challenge-string", + }, + { + name: "wrong label", + env: []byte(`["EVENT","random-challenge-string"]`), + wantErr: errors.WrongEnvelopeLabel, + wantErrText: "expected AUTH, got EVENT", + }, + { + name: "invalid json", + env: []byte(`invalid`), + wantErr: errors.InvalidJSON, + }, + { + name: "missing elements", + env: []byte(`["AUTH"]`), + wantErr: errors.InvalidEnvelope, + wantErrText: "expected 2 elements, got 1", + }, + { + name: "extraneous elements", + env: []byte(`["AUTH","random-challenge-string","extra"]`), + wantChallenge: "random-challenge-string", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotChallenge, err := FindAuthChallenge(tc.env) + + if tc.wantErr != nil || tc.wantErrText != "" { + if tc.wantErr != nil { + assert.ErrorIs(t, err, tc.wantErr) + } + + if tc.wantErrText != "" { + assert.ErrorContains(t, err, tc.wantErrText) + } + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.wantChallenge, gotChallenge) + }) + } +} + +func TestFindAuthResponse(t *testing.T) { + cases := []struct { + name string + env Envelope + wantEvent []byte + wantErr error + wantErrText string + }{ + { + name: "valid AUTH response", + env: []byte(`["AUTH",{"id":"abc123","kind":22242}]`), + wantEvent: []byte(`{"id":"abc123","kind":22242}`), + }, + { + name: "wrong label", + env: []byte(`["EVENT",{"id":"abc123","kind":22242}]`), + wantErr: errors.WrongEnvelopeLabel, + wantErrText: "expected AUTH, got EVENT", + }, + { + name: "invalid json", + env: []byte(`invalid`), + wantErr: errors.InvalidJSON, + }, + { + name: "missing elements", + env: []byte(`["AUTH"]`), + wantErr: errors.InvalidEnvelope, + wantErrText: "expected 2 elements, got 1", + }, + { + name: "extraneous elements", + env: []byte(`["AUTH",{"id":"abc123","kind":22242},"extra"]`), + wantEvent: []byte(`{"id":"abc123","kind":22242}`), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotEvent, err := FindAuthResponse(tc.env) + + if tc.wantErr != nil || tc.wantErrText != "" { + if tc.wantErr != nil { + assert.ErrorIs(t, err, tc.wantErr) + } + + if tc.wantErrText != "" { + assert.ErrorContains(t, err, tc.wantErrText) + } + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.wantEvent, gotEvent) + }) + } +} diff --git a/errors/errors.go b/errors/errors.go new file mode 100644 index 0000000..16783c5 --- /dev/null +++ b/errors/errors.go @@ -0,0 +1,33 @@ +// Package errors defines standard error types used throughout the roots-ws library. +package errors + +import ( + "errors" +) + +var ( + // Data Structure Errors + + // InvalidJSON indicates that a byte sequence could not be parsed as valid JSON. + // This is typically returned when unmarshaling fails during envelope processing. + InvalidJSON = errors.New("invalid JSON") + + // MissingField indicates that a required field is absent from a data structure. + // This is returned when validating that all mandatory components are present. + MissingField = errors.New("missing required field") + + // WrongFieldType indicates that a field's type does not match the expected type. + // This is returned when unmarshaling a specific value fails due to type mismatch. + WrongFieldType = errors.New("wrong field type") + + // Envelope Errors + + // InvalidEnvelope indicates that a message does not conform to the Nostr envelope structure. + // This typically occurs when an array has incorrect number of elements for its message type. + InvalidEnvelope = errors.New("invalid envelope format") + + // WrongEnvelopeLabel indicates that an envelope's label does not match the expected type. + // This is returned when attempting to parse an envelope using a Find function that + // expects a different label than what was provided. + WrongEnvelopeLabel = errors.New("wrong envelope label") +) diff --git a/go.mod b/go.mod index 4779cce..f7eea27 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,11 @@ module git.wisehodl.dev/jay/go-roots-ws go 1.23.5 + +require github.com/stretchr/testify v1.11.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c4c1710 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/roots-ws.go b/roots-ws.go new file mode 100644 index 0000000..4b201e7 --- /dev/null +++ b/roots-ws.go @@ -0,0 +1,18 @@ +package roots_ws + +// ConnectionStatus represents the current state of a WebSocket connection. +type ConnectionStatus int + +const ( + // StatusDisconnected indicates the connection is not active and no connection attempt is in progress. + StatusDisconnected ConnectionStatus = iota + + // StatusConnecting indicates a connection attempt is currently in progress but not yet established. + StatusConnecting + + // StatusConnected indicates the connection is active and ready for message exchange. + StatusConnected + + // StatusClosing indicates the connection is in the process of shutting down gracefully. + StatusClosing +) diff --git a/roots-ws_test.go b/roots-ws_test.go new file mode 100644 index 0000000..c79d8f6 --- /dev/null +++ b/roots-ws_test.go @@ -0,0 +1,21 @@ +package roots_ws + +import "testing" + +func TestConnectionStatusConstants(t *testing.T) { + seen := make(map[ConnectionStatus]bool) + + constants := []ConnectionStatus{ + StatusDisconnected, + StatusConnecting, + StatusConnected, + StatusClosing, + } + + for i, status := range constants { + if seen[status] { + t.Errorf("Duplicate value found for constant at index %d: %d", i, status) + } + seen[status] = true + } +}