Wrote roots-ws golang implementation.
This commit is contained in:
21
LICENSE
Normal file
21
LICENSE
Normal 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
332
README.md
Normal 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
121
envelope/enclose.go
Normal 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
289
envelope/enclose_test.go
Normal 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
55
envelope/envelope.go
Normal 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
110
envelope/envelope_test.go
Normal 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
324
envelope/find.go
Normal 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
630
envelope/find_test.go
Normal 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
33
errors/errors.go
Normal 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
8
go.mod
@@ -1,3 +1,11 @@
|
|||||||
module git.wisehodl.dev/jay/go-roots-ws
|
module git.wisehodl.dev/jay/go-roots-ws
|
||||||
|
|
||||||
go 1.23.5
|
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
10
go.sum
Normal 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
18
roots-ws.go
Normal 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
21
roots-ws_test.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user