Skip to content

Commit

Permalink
Add support for arbitrary single-letter tags in Filter (nostr-sdk#154)
Browse files Browse the repository at this point in the history
  • Loading branch information
tyiu authored and RandyMcMillan committed Sep 1, 2024
1 parent 290e886 commit 56e08ef
Show file tree
Hide file tree
Showing 7 changed files with 214 additions and 83 deletions.
113 changes: 94 additions & 19 deletions Sources/NostrSDK/Filter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,10 @@ public struct Filter: Codable, Hashable, Equatable {

/// a list of a kind numbers
public let kinds: [Int]?

/// a list of event ids that are referenced in an "e" tag
public let events: [String]?

/// a list of pubkeys that are referenced in a "p" tag
public let pubkeys: [String]?


/// a list of tag values that are referenced by single basic Latin letter tag names
public let tags: [Character: [String]]?

/// an integer unix timestamp, events must be newer than this to pass
public let since: Int?

Expand All @@ -36,16 +33,28 @@ public struct Filter: Codable, Hashable, Equatable {
public let limit: Int?

private enum CodingKeys: String, CodingKey {
case ids = "ids"
case authors = "authors"
case kinds = "kinds"
case events = "#e"
case pubkeys = "#p"
case since = "since"
case until = "until"
case limit = "limit"
case ids
case authors
case kinds
case since
case until
case limit
}


private struct TagFilterName: CodingKey {
let stringValue: String

init(stringValue: String) {
self.stringValue = stringValue
}

var intValue: Int? { nil }

init?(intValue: Int) {
return nil
}
}

/// Creates and returns a filter with the specified parameters.
///
/// - Parameters:
Expand All @@ -54,19 +63,85 @@ public struct Filter: Codable, Hashable, Equatable {
/// - kinds: a list of a kind numbers
/// - events: a list of event ids that are referenced in an "e" tag
/// - pubkeys: a list of pubkeys that are referenced in a "p" tag
/// - tags: a list of tag values that are referenced by single basic Latin letter tag names
/// - since: an integer unix timestamp, events must be newer than this to pass
/// - until: an integer unix timestamp, events must be older than this to pass
/// - limit: maximum number of events to be returned in the initial query
///
/// If `tags` contains an `e` tag and `events` is also provided, `events` takes precedence.
/// If `tags` contains a `p` tag and `pubkeys` is also provided, `pubkeys` takes precedence.
///
/// Returns `nil` if `tags` contains tag names that are not in the basic Latin alphabet of A-Z or a-z.
///
/// > Important: Event ids and pubkeys should be in the 32-byte hexadecimal format, not the `note...` and `npub...` formats.
public init(ids: [String]? = nil, authors: [String]? = nil, kinds: [Int]? = nil, events: [String]? = nil, pubkeys: [String]? = nil, since: Int? = nil, until: Int? = nil, limit: Int? = nil) {
public init?(ids: [String]? = nil, authors: [String]? = nil, kinds: [Int]? = nil, events: [String]? = nil, pubkeys: [String]? = nil, tags: [Character: [String]]? = nil, since: Int? = nil, until: Int? = nil, limit: Int? = nil) {
self.ids = ids
self.authors = authors
self.kinds = kinds
self.events = events
self.pubkeys = pubkeys
self.since = since
self.until = until
self.limit = limit

if let tags {
guard tags.keys.allSatisfy({ $0.isBasicLatinLetter }) else {
return nil
}
}

var tagsBuilder: [Character: [String]] = tags ?? [:]
if let events {
tagsBuilder["e"] = events
}
if let pubkeys {
tagsBuilder["p"] = pubkeys
}
self.tags = tagsBuilder
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)

ids = try container.decodeIfPresent([String].self, forKey: .ids)
authors = try container.decodeIfPresent([String].self, forKey: .authors)
kinds = try container.decodeIfPresent([Int].self, forKey: .kinds)
since = try container.decodeIfPresent(Int.self, forKey: .since)
until = try container.decodeIfPresent(Int.self, forKey: .until)
limit = try container.decodeIfPresent(Int.self, forKey: .limit)

if let tagsContainer = try? decoder.container(keyedBy: TagFilterName.self) {
var decodedTags: [Character: [String]] = [:]
for key in tagsContainer.allKeys {
let tagName = key.stringValue

if tagName.count == 2 && tagName.first == "#", let tagCharacter = tagName.last, tagCharacter.isBasicLatinLetter {
decodedTags[tagCharacter] = try tagsContainer.decode([String].self, forKey: key)
}
}
tags = decodedTags.isEmpty ? nil : decodedTags
} else {
tags = nil
}
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)

try container.encodeIfPresent(self.ids, forKey: .ids)
try container.encodeIfPresent(self.authors, forKey: .authors)
try container.encodeIfPresent(self.kinds, forKey: .kinds)
try container.encodeIfPresent(self.since, forKey: .since)
try container.encodeIfPresent(self.until, forKey: .until)
try container.encodeIfPresent(self.limit, forKey: .limit)

var tagsContainer = encoder.container(keyedBy: TagFilterName.self)
try self.tags?.forEach {
try tagsContainer.encode($0.value, forKey: TagFilterName(stringValue: "#\($0.key)"))
}
}
}

private extension Character {
var isBasicLatinLetter: Bool {
(self >= "A" && self <= "Z") || (self >= "a" && self <= "z")
}
}
45 changes: 0 additions & 45 deletions Tests/NostrSDKTests/FilterEncodingTests.swift

This file was deleted.

78 changes: 78 additions & 0 deletions Tests/NostrSDKTests/FilterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//
// FilterTests.swift
//
//
// Created by Joel Klabo on 5/26/23.
//

import NostrSDK
import XCTest

final class FilterTests: XCTestCase, FixtureLoading, JSONTesting {

func testFilterEncoding() throws {
let filter = Filter(authors: ["d9fa34214aa9d151c4f4db843e9c2af4f246bab4205137731f91bcfa44d66a62"],
kinds: [3],
limit: 1)

let expected = try loadFixtureString("filter")

let encoder = JSONEncoder()
let result = try encoder.encode(filter)
let resultString = String(decoding: result, as: UTF8.self)

XCTAssertTrue(areEquivalentJSONObjectStrings(expected, resultString))
}

func testFilterWithAllFieldsEncoding() throws {
let filter = Filter(ids: ["pubkey1"],
authors: ["author1", "author2"],
kinds: [1, 2, 3],
events: ["event1", "event2"],
pubkeys: ["referencedPubkey1"],
tags: ["t": ["hashtag"], "e": ["thisEventFilterIsDiscarded"], "p": ["thisPubkeyFilterIsDiscarded"]],
since: 1234,
until: 12345,
limit: 5)

let expected = try loadFixtureString("filter_all_fields")

let encoder = JSONEncoder()
let result = try encoder.encode(filter)
let resultString = String(decoding: result, as: UTF8.self)

XCTAssertTrue(areEquivalentJSONObjectStrings(expected, resultString))
}

func testFilterWithInvalidTagsEncoding() throws {
XCTAssertNil(Filter(tags: ["*": []]))
}

func testFilterDecoding() throws {
let expectedFilter = Filter(ids: ["pubkey1"],
authors: ["author1", "author2"],
kinds: [1, 2, 3],
events: ["event1", "event2"],
pubkeys: ["referencedPubkey1"],
tags: ["t": ["hashtag"]],
since: 1234,
until: 12345,
limit: 5)
let filter: Filter = try decodeFixture(filename: "filter_all_fields")
XCTAssertEqual(expectedFilter, filter)
}

func testFilterWithExtraFieldsDecoding() throws {
let expectedFilter = Filter(ids: ["pubkey1"],
authors: ["author1", "author2"],
kinds: [1, 2, 3],
events: ["event1", "event2"],
pubkeys: ["referencedPubkey1"],
tags: ["t": ["hashtag"]],
since: 1234,
until: 12345,
limit: 5)
let filter: Filter = try decodeFixture(filename: "filter_with_extra_fields")
XCTAssertEqual(expectedFilter, filter)
}
}
1 change: 1 addition & 0 deletions Tests/NostrSDKTests/Fixtures/filter_all_fields.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"kinds": [1, 2, 3],
"#e": ["event1", "event2"],
"#p": ["referencedPubkey1"],
"#t": ["hashtag"],
"since": 1234,
"until": 12345,
"limit": 5
Expand Down
13 changes: 13 additions & 0 deletions Tests/NostrSDKTests/Fixtures/filter_with_extra_fields.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"ids": ["pubkey1"],
"authors": ["author1", "author2"],
"kinds": [1, 2, 3],
"#e": ["event1", "event2"],
"#p": ["referencedPubkey1"],
"#t": ["hashtag"],
"since": 1234,
"until": 12345,
"limit": 5,
"#unrecognized": ["unrecognized"],
"unrecognized": 123
}
38 changes: 22 additions & 16 deletions Tests/NostrSDKTests/RelayRequestEncodingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,17 @@ final class RelayRequestEncodingTests: XCTestCase, EventCreating, FixtureLoading
}

func testEncodeReq() throws {
let filter = Filter(ids: nil,
authors: ["some-pubkey"],
kinds: [1, 7],
events: nil,
pubkeys: nil,
since: nil,
until: nil,
limit: nil)
let filter = try XCTUnwrap(
Filter(ids: nil,
authors: ["some-pubkey"],
kinds: [1, 7],
events: nil,
pubkeys: nil,
since: nil,
until: nil,
limit: nil
)
)

let request = try XCTUnwrap(RelayRequest.request(subscriptionId: "some-subscription-id", filter: filter), "failed to encode request")
let expected = try loadFixtureString("req")
Expand All @@ -59,14 +62,17 @@ final class RelayRequestEncodingTests: XCTestCase, EventCreating, FixtureLoading
}

func testEncodeCount() throws {
let filter = Filter(ids: nil,
authors: ["some-pubkey"],
kinds: [1, 7],
events: nil,
pubkeys: nil,
since: nil,
until: nil,
limit: nil)
let filter = try XCTUnwrap(
Filter(ids: nil,
authors: ["some-pubkey"],
kinds: [1, 7],
events: nil,
pubkeys: nil,
since: nil,
until: nil,
limit: nil
)
)

let request = try XCTUnwrap(RelayRequest.count(subscriptionId: "some-subscription-id", filter: filter), "failed to encode request")
let expected = try loadFixtureString("count_request")
Expand Down
9 changes: 6 additions & 3 deletions Tests/NostrSDKTests/RelayTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ final class RelayTests: XCTestCase {

wait(for: [connectExpectation!], timeout: 10)

let subscriptionId = try relay.subscribe(with: Filter(kinds: [1], limit: 1))
let filter = try XCTUnwrap(Filter(kinds: [1], limit: 1))
let subscriptionId = try relay.subscribe(with: filter)

relay.events
.sink { [unowned relay] _ in
Expand All @@ -65,7 +66,8 @@ final class RelayTests: XCTestCase {

func testSubscribeWithoutConnection() throws {
let relay = try Relay(url: RelayTests.RelayURL)
XCTAssertThrowsError(try relay.subscribe(with: Filter(kinds: [1], limit: 1))) {
let filter = try XCTUnwrap(Filter(kinds: [1], limit: 1))
XCTAssertThrowsError(try relay.subscribe(with: filter)) {
XCTAssertEqual($0 as? RelayRequestError, RelayRequestError.notConnected)
}
}
Expand All @@ -88,7 +90,8 @@ final class RelayTests: XCTestCase {

wait(for: [connectExpectation!], timeout: 10)

let subscriptionId = try relay.subscribe(with: Filter(kinds: [1], limit: 1))
let filter = try XCTUnwrap(Filter(kinds: [1], limit: 1))
let subscriptionId = try relay.subscribe(with: filter)

wait(for: [receiveExpectation!], timeout: 10)

Expand Down

0 comments on commit 56e08ef

Please sign in to comment.