Wrote roots-ws golang implementation.

This commit is contained in:
Jay
2025-11-02 14:31:46 -05:00
parent 53c42911b4
commit 82e58a193d
13 changed files with 1972 additions and 0 deletions

21
LICENSE Normal file
View File

@@ -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.

332
README.md Normal file
View File

@@ -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 ./...
```

121
envelope/enclose.go Normal file
View File

@@ -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()
}

289
envelope/enclose_test.go Normal file
View File

@@ -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)
})
}
}

55
envelope/envelope.go Normal file
View File

@@ -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
}

110
envelope/envelope_test.go Normal file
View File

@@ -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)
})
}
}

324
envelope/find.go Normal file
View File

@@ -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
}

630
envelope/find_test.go Normal file
View File

@@ -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)
})
}
}

33
errors/errors.go Normal file
View File

@@ -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")
)

8
go.mod
View File

@@ -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
)

10
go.sum Normal file
View File

@@ -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=

18
roots-ws.go Normal file
View File

@@ -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
)

21
roots-ws_test.go Normal file
View File

@@ -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
}
}