diff --git a/Sources/NostrSDK/Filter.swift b/Sources/NostrSDK/Filter.swift index 2b8b927..40e0c6d 100644 --- a/Sources/NostrSDK/Filter.swift +++ b/Sources/NostrSDK/Filter.swift @@ -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? @@ -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: @@ -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") } } diff --git a/Tests/NostrSDKTests/FilterEncodingTests.swift b/Tests/NostrSDKTests/FilterEncodingTests.swift deleted file mode 100644 index 0148f08..0000000 --- a/Tests/NostrSDKTests/FilterEncodingTests.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// FilterEncodingTests.swift -// -// -// Created by Joel Klabo on 5/26/23. -// - -import NostrSDK -import XCTest - -final class FilterEncodingTests: 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"], - 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)) - } -} diff --git a/Tests/NostrSDKTests/FilterTests.swift b/Tests/NostrSDKTests/FilterTests.swift new file mode 100644 index 0000000..951db47 --- /dev/null +++ b/Tests/NostrSDKTests/FilterTests.swift @@ -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) + } +} diff --git a/Tests/NostrSDKTests/Fixtures/filter_all_fields.json b/Tests/NostrSDKTests/Fixtures/filter_all_fields.json index 2a9d77e..f1813bc 100644 --- a/Tests/NostrSDKTests/Fixtures/filter_all_fields.json +++ b/Tests/NostrSDKTests/Fixtures/filter_all_fields.json @@ -4,6 +4,7 @@ "kinds": [1, 2, 3], "#e": ["event1", "event2"], "#p": ["referencedPubkey1"], + "#t": ["hashtag"], "since": 1234, "until": 12345, "limit": 5 diff --git a/Tests/NostrSDKTests/Fixtures/filter_with_extra_fields.json b/Tests/NostrSDKTests/Fixtures/filter_with_extra_fields.json new file mode 100644 index 0000000..c9a55c4 --- /dev/null +++ b/Tests/NostrSDKTests/Fixtures/filter_with_extra_fields.json @@ -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 +} diff --git a/Tests/NostrSDKTests/RelayRequestEncodingTests.swift b/Tests/NostrSDKTests/RelayRequestEncodingTests.swift index 6f5950a..5c0b72f 100644 --- a/Tests/NostrSDKTests/RelayRequestEncodingTests.swift +++ b/Tests/NostrSDKTests/RelayRequestEncodingTests.swift @@ -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") @@ -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") diff --git a/Tests/NostrSDKTests/RelayTests.swift b/Tests/NostrSDKTests/RelayTests.swift index 5797ea8..5780625 100644 --- a/Tests/NostrSDKTests/RelayTests.swift +++ b/Tests/NostrSDKTests/RelayTests.swift @@ -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 @@ -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) } } @@ -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)