From 4da2bbc934377d1a0903181776cd6c89f5be6098 Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Tue, 25 Jul 2023 06:30:31 -0500 Subject: [PATCH] establish NostrEvent subclassing pattern with basic event kinds (#56) * establish NostrEvent subclassing pattern with basic event kinds Closes https://github.com/nostr-sdk/nostr-sdk-ios/issues/48 Closes https://github.com/nostr-sdk/nostr-sdk-ios/issues/49 Closes https://github.com/nostr-sdk/nostr-sdk-ios/issues/50 Closes https://github.com/nostr-sdk/nostr-sdk-ios/issues/51 * lint * validate relay URLs * add external interface for creating set metadata and recommend server events, plus tests --- Sources/NostrSDK/EventCreating.swift | 48 +++++++++++-- .../NostrSDK/{ => Events}/NostrEvent.swift | 2 +- .../Events/RecommendServerEvent.swift | 21 ++++++ .../NostrSDK/Events/SetMetadataEvent.swift | 71 +++++++++++++++++++ Sources/NostrSDK/Events/TextNoteEvent.swift | 26 +++++++ Sources/NostrSDK/RelayResponse.swift | 29 +++++++- Tests/NostrSDKTests/EventCreatingTests.swift | 39 ++++++++++ Tests/NostrSDKTests/EventDecodingTests.swift | 39 +++++++++- .../Fixtures/recommend_server.json | 9 +++ 9 files changed, 274 insertions(+), 10 deletions(-) rename Sources/NostrSDK/{ => Events}/NostrEvent.swift (98%) create mode 100644 Sources/NostrSDK/Events/RecommendServerEvent.swift create mode 100644 Sources/NostrSDK/Events/SetMetadataEvent.swift create mode 100644 Sources/NostrSDK/Events/TextNoteEvent.swift create mode 100644 Tests/NostrSDKTests/Fixtures/recommend_server.json diff --git a/Sources/NostrSDK/EventCreating.swift b/Sources/NostrSDK/EventCreating.swift index 4b893be..0bcf32c 100644 --- a/Sources/NostrSDK/EventCreating.swift +++ b/Sources/NostrSDK/EventCreating.swift @@ -7,15 +7,51 @@ import Foundation +enum EventCreatingError: Error { + case invalidInput +} + public protocol EventCreating {} public extension EventCreating { - /// Creates a text note event (kind 1) and signs it with the provided ``Keypair`` + /// Creates a set metadata event (kind 0) and signs it with the provided ``Keypair``. + /// - Parameters: + /// - userMetadata: The metadata to set. + /// - keypair: The Keypair to sign with. + /// - Returns: The signed set metadata event. + /// + /// See [NIP-01 - Basic Event Kinds](https://github.com/nostr-protocol/nips/blob/master/01.md#basic-event-kinds) + func setMetadataEvent(withUserMetadata userMetadata: UserMetadata, signedBy keypair: Keypair) throws -> SetMetadataEvent { + let metadataAsData = try JSONEncoder().encode(userMetadata) + guard let metadataAsString = String(data: metadataAsData, encoding: .utf8) else { + throw EventCreatingError.invalidInput + } + return try SetMetadataEvent(kind: .setMetadata, content: metadataAsString, signedBy: keypair) + } + + /// Creates a text note event (kind 1) and signs it with the provided ``Keypair``. + /// - Parameters: + /// - content: The content of the text note. + /// - keypair: The Keypair to sign with. + /// - Returns: The signed text note event. + /// + /// See [NIP-01 - Basic Event Kinds](https://github.com/nostr-protocol/nips/blob/master/01.md#basic-event-kinds) + func textNote(withContent content: String, signedBy keypair: Keypair) throws -> TextNoteEvent { + try TextNoteEvent(kind: .textNote, content: content, signedBy: keypair) + } + + /// Creates a recommend server event (kind 2) and signs it with the provided `Keypair``.` /// - Parameters: - /// - content: The content of the text note - /// - keypair: The Keypair to sign with - /// - Returns: The signed text note event - func textNote(withContent content: String, signedBy keypair: Keypair) throws -> NostrEvent { - try NostrEvent(kind: .textNote, content: content, signedBy: keypair) + /// - relayURL: The URL of the relay, which must be a websocket URL. + /// - keypair: The Keypair to sign with. + /// - Returns: The signed recommend server event. + /// + /// See [NIP-01 - Basic Event Kinds](https://github.com/nostr-protocol/nips/blob/master/01.md#basic-event-kinds) + func recommendServerEvent(withRelayURL relayURL: URL, signedBy keypair: Keypair) throws -> RecommendServerEvent { + let components = URLComponents(url: relayURL, resolvingAgainstBaseURL: false) + guard components?.scheme == "wss" || components?.scheme == "ws" else { + throw EventCreatingError.invalidInput + } + return try RecommendServerEvent(kind: .recommendServer, content: relayURL.absoluteString, signedBy: keypair) } } diff --git a/Sources/NostrSDK/NostrEvent.swift b/Sources/NostrSDK/Events/NostrEvent.swift similarity index 98% rename from Sources/NostrSDK/NostrEvent.swift rename to Sources/NostrSDK/Events/NostrEvent.swift index 5182182..a2cb4e6 100644 --- a/Sources/NostrSDK/NostrEvent.swift +++ b/Sources/NostrSDK/Events/NostrEvent.swift @@ -10,7 +10,7 @@ import Foundation /// A structure that describes a Nostr event. /// /// > Note: [NIP-01 Specification](https://github.com/nostr-protocol/nips/blob/master/01.md#events-and-signatures) -public struct NostrEvent: Codable { +public class NostrEvent: Codable { /// 32-byte, lowercase, hex-encoded sha256 of the serialized event data public let id: String diff --git a/Sources/NostrSDK/Events/RecommendServerEvent.swift b/Sources/NostrSDK/Events/RecommendServerEvent.swift new file mode 100644 index 0000000..ba902e3 --- /dev/null +++ b/Sources/NostrSDK/Events/RecommendServerEvent.swift @@ -0,0 +1,21 @@ +// +// RecommendServerEvent.swift +// +// +// Created by Bryan Montz on 7/23/23. +// + +import Foundation + +/// An event that contains a relay the event creator wants to recommend to its followers. +/// +/// > Note: [NIP-01 Specification](https://github.com/nostr-protocol/nips/blob/b503f8a92b22be3037b8115fe3e644865a4fa155/01.md#basic-event-kinds) +public final class RecommendServerEvent: NostrEvent { + public var relayURL: URL? { + let components = URLComponents(string: content) + guard components?.scheme == "wss" || components?.scheme == "ws" else { + return nil + } + return components?.url + } +} diff --git a/Sources/NostrSDK/Events/SetMetadataEvent.swift b/Sources/NostrSDK/Events/SetMetadataEvent.swift new file mode 100644 index 0000000..a0a42c2 --- /dev/null +++ b/Sources/NostrSDK/Events/SetMetadataEvent.swift @@ -0,0 +1,71 @@ +// +// SetMetadataEvent.swift +// +// +// Created by Bryan Montz on 7/22/23. +// + +import Foundation + +/// An object that describes a user. +public struct UserMetadata: Codable { + + /// The user's name. + public let name: String? + + /// The user's description of themself. + public let about: String? + + /// The user's website address. + public let website: URL? + + /// The user's Nostr address. + /// + /// > Note: [NIP-05 Specification](https://github.com/nostr-protocol/nips/blob/master/05.md#nip-05). + public let nostrAddress: String? + + /// A URL to retrieve the user's picture. + public let pictureURL: URL? + + /// A URL to retrieve the user's banner image. + public let bannerPictureURL: URL? + + enum CodingKeys: String, CodingKey { + case name, about, website + case nostrAddress = "nip05" + case pictureURL = "picture" + case bannerPictureURL = "banner" + } + + public init(name: String?, about: String?, website: URL?, nostrAddress: String?, pictureURL: URL?, bannerPictureURL: URL?) { + self.name = name + self.about = about + self.website = website + self.nostrAddress = nostrAddress + self.pictureURL = pictureURL + self.bannerPictureURL = bannerPictureURL + } +} + +/// An event that contains a user profile. +/// +/// > Note: [NIP-01 Specification](https://github.com/nostr-protocol/nips/blob/b503f8a92b22be3037b8115fe3e644865a4fa155/01.md#basic-event-kinds) +public final class SetMetadataEvent: NostrEvent { + + /// A dictionary containing all of the properties in the `content` field of the ``NostrEvent``. + public var rawUserMetadata: [String: Any] { + guard let data = content.data(using: .utf8) else { + return [:] + } + let dict = try? JSONSerialization.jsonObject(with: data) + return dict as? [String: Any] ?? [:] + } + + /// An object that contains decoded user properties from the `content` field of the ``NostrEvent``. + public var userMetadata: UserMetadata? { + guard let data = content.data(using: .utf8) else { + return nil + } + return try? JSONDecoder().decode(UserMetadata.self, from: data) + } +} diff --git a/Sources/NostrSDK/Events/TextNoteEvent.swift b/Sources/NostrSDK/Events/TextNoteEvent.swift new file mode 100644 index 0000000..3dfa2ad --- /dev/null +++ b/Sources/NostrSDK/Events/TextNoteEvent.swift @@ -0,0 +1,26 @@ +// +// TextNoteEvent.swift +// +// +// Created by Bryan Montz on 7/23/23. +// + +import Foundation + +/// An event that contains plaintext content. +/// +/// > Note: [NIP-01 Specification](https://github.com/nostr-protocol/nips/blob/b503f8a92b22be3037b8115fe3e644865a4fa155/01.md#basic-event-kinds) +public final class TextNoteEvent: NostrEvent { + + /// Pubkeys mentioned in the note content. + public var mentionedPubkeys: [String] { + let pubkeyTags = tags.filter { $0.identifier == .pubkey } + return pubkeyTags.map { $0.contentIdentifier } + } + + /// Events mentioned in the note content. + public var mentionedEventIds: [String] { + let eventTags = tags.filter { $0.identifier == .event } + return eventTags.map { $0.contentIdentifier } + } +} diff --git a/Sources/NostrSDK/RelayResponse.swift b/Sources/NostrSDK/RelayResponse.swift index 7349a91..4529378 100644 --- a/Sources/NostrSDK/RelayResponse.swift +++ b/Sources/NostrSDK/RelayResponse.swift @@ -7,6 +7,25 @@ import Foundation +/// A type used for decoding and mapping a kind number to a ``NostrEvent`` subclass. +fileprivate struct EventKindMapper: Decodable { // swiftlint:disable:this private_over_fileprivate + let kind: EventKind + + enum CodingKeys: CodingKey { + case kind + } + + /// The ``NostrEvent`` subclass associated with the kind. + var classForKind: NostrEvent.Type { + switch kind { + case .setMetadata: return SetMetadataEvent.self + case .textNote: return TextNoteEvent.self + case .recommendServer: return RecommendServerEvent.self + default: return NostrEvent.self + } + } +} + enum RelayResponse: Decodable { struct CountResponse: Codable { @@ -35,7 +54,15 @@ enum RelayResponse: Decodable { switch responseType { case .event: let subscriptionId = try container.decode(String.self) - let event = try container.decode(NostrEvent.self) + let kindMapper = try container.decode(EventKindMapper.self) + + // Since the decoding index in the container cannot be decremented, create a + // new container so we can use the class from the mapper. + var container2 = try decoder.unkeyedContainer() + _ = try? container2.decode(MessageType.self) + _ = try? container2.decode(String.self) + let event = try container2.decode(kindMapper.classForKind.self) + self = .event(subscriptionId: subscriptionId, event: event) case .notice: let message = try container.decode(String.self) diff --git a/Tests/NostrSDKTests/EventCreatingTests.swift b/Tests/NostrSDKTests/EventCreatingTests.swift index 5fa9e42..5b540d7 100644 --- a/Tests/NostrSDKTests/EventCreatingTests.swift +++ b/Tests/NostrSDKTests/EventCreatingTests.swift @@ -11,6 +11,26 @@ import XCTest final class EventCreatingTests: XCTestCase, EventCreating, EventVerifying { + func testCreateSetMetadataEvent() throws { + let meta = UserMetadata(name: "Nostr SDK Test", + about: "I'm a test account. I'm used to test the Nostr SDK for Apple platforms.", + website: URL(string: "https://github.com/nostr-sdk/nostr-sdk-ios")!, + nostrAddress: "test@nostr.com", + pictureURL: URL(string: "picture@nostr.com"), + bannerPictureURL: URL(string: "banner@nostr.com")!) + + let event = try setMetadataEvent(withUserMetadata: meta, signedBy: Keypair.test) + + XCTAssertEqual(event.userMetadata?.name, "Nostr SDK Test") + XCTAssertEqual(event.userMetadata?.about, "I'm a test account. I'm used to test the Nostr SDK for Apple platforms.") + XCTAssertEqual(event.userMetadata?.website, URL(string: "https://github.com/nostr-sdk/nostr-sdk-ios")) + XCTAssertEqual(event.userMetadata?.nostrAddress, "test@nostr.com") + XCTAssertEqual(event.userMetadata?.pictureURL, URL(string: "picture@nostr.com")) + XCTAssertEqual(event.userMetadata?.bannerPictureURL, URL(string: "banner@nostr.com")) + + try verifyEvent(event) + } + func testCreateSignedTextNote() throws { let note = try textNote(withContent: "Hello world!", signedBy: Keypair.test) @@ -22,4 +42,23 @@ final class EventCreatingTests: XCTestCase, EventCreating, EventVerifying { try verifyEvent(note) } + + func testCreateRecommendServerEvent() throws { + let inputURL = URL(string: "wss://relay.test")! + let event = try recommendServerEvent(withRelayURL: inputURL, + signedBy: Keypair.test) + + XCTAssertEqual(event.kind, .recommendServer) + XCTAssertEqual(event.relayURL, inputURL) + XCTAssertEqual(event.pubkey, Keypair.test.publicKey.hex) + XCTAssertEqual(event.tags, []) + + try verifyEvent(event) + } + + func testRecommendServerEventFailsWithNonWebsocketURL() throws { + let inputURL = URL(string: "https://not-a-socket.com")! + XCTAssertThrowsError(try recommendServerEvent(withRelayURL: inputURL, + signedBy: Keypair.test)) + } } diff --git a/Tests/NostrSDKTests/EventDecodingTests.swift b/Tests/NostrSDKTests/EventDecodingTests.swift index 4282ad1..1742f22 100644 --- a/Tests/NostrSDKTests/EventDecodingTests.swift +++ b/Tests/NostrSDKTests/EventDecodingTests.swift @@ -12,7 +12,7 @@ final class EventDecodingTests: XCTestCase, FixtureLoading { func testDecodeSetMetadata() throws { - let event: NostrEvent = try decodeFixture(filename: "set_metadata") + let event: SetMetadataEvent = try decodeFixture(filename: "set_metadata") XCTAssertEqual(event.id, "d214c914b0ab49ec919fa5f60fabf746f421e432d96f941bd2573e4d22e36b51") XCTAssertEqual(event.pubkey, "00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700") @@ -21,11 +21,28 @@ final class EventDecodingTests: XCTestCase, FixtureLoading { XCTAssertEqual(event.tags, []) XCTAssertTrue(event.content.hasPrefix("{\"banner\":\"https://nostr.build/i/nostr.build")) XCTAssertEqual(event.signature, "7bb7f031fbf41f49eeb44fdfb061bc8d143197d33fae8d29b017709adf2b17c76e78ccb2ee128ee93d0661cad4c626a747d48a178745c94944a693ff31ea7619") + + // access metadata properties from raw dictionary + XCTAssertEqual(event.rawUserMetadata["name"] as? String, "cameri") + XCTAssertEqual(event.rawUserMetadata["about"] as? String, "@HodlWithLedn. All opinions are my own.\nBitcoiner class of 2021. Core Nostr Developer. Author of Nostream. Professional Relay Operator.") + XCTAssertEqual(event.rawUserMetadata["website"] as? String, "https://primal.net/cameri") + XCTAssertEqual(event.rawUserMetadata["nip05"] as? String, "cameri@elder.nostr.land") + XCTAssertEqual(event.rawUserMetadata["picture"] as? String, "https://nostr.build/i/9396d5cd901304726883aea7363543f121e1d53964dd3149cadecd802608aebe.jpg") + XCTAssertEqual(event.rawUserMetadata["banner"] as? String, "https://nostr.build/i/nostr.build_90a51a2e50c9f42288260d01b3a2a4a1c7a9df085423abad7809e76429da7cdc.gif") + + // access metadata properties from decoded object + let userMetadata = try XCTUnwrap(event.userMetadata) + XCTAssertEqual(userMetadata.name, "cameri") + XCTAssertEqual(userMetadata.about, "@HodlWithLedn. All opinions are my own.\nBitcoiner class of 2021. Core Nostr Developer. Author of Nostream. Professional Relay Operator.") + XCTAssertEqual(userMetadata.website, URL(string: "https://primal.net/cameri")) + XCTAssertEqual(userMetadata.nostrAddress, "cameri@elder.nostr.land") + XCTAssertEqual(userMetadata.pictureURL, URL(string: "https://nostr.build/i/9396d5cd901304726883aea7363543f121e1d53964dd3149cadecd802608aebe.jpg")) + XCTAssertEqual(userMetadata.bannerPictureURL, URL(string: "https://nostr.build/i/nostr.build_90a51a2e50c9f42288260d01b3a2a4a1c7a9df085423abad7809e76429da7cdc.gif")) } func testDecodeTextNote() throws { - let event: NostrEvent = try decodeFixture(filename: "text_note") + let event: TextNoteEvent = try decodeFixture(filename: "text_note") XCTAssertEqual(event.id, "fa5ed84fc8eeb959fd39ad8e48388cfc33075991ef8e50064cfcecfd918bb91b") XCTAssertEqual(event.pubkey, "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2") @@ -39,6 +56,24 @@ final class EventDecodingTests: XCTestCase, FixtureLoading { XCTAssertEqual(event.tags, expectedTags) XCTAssertEqual(event.content, "I think it stays persistent on your profile, but interface setting doesn’t persist. Bug. ") XCTAssertEqual(event.signature, "96e6667348b2b1fc5f6e73e68fb1605f571ad044077dda62a35c15eb8290f2c4559935db461f8466df3dcf39bc2e11984c5344f65aabee4520dd6653d74cdc09") + + XCTAssertEqual(event.mentionedPubkeys, ["f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9"]) + XCTAssertEqual(event.mentionedEventIds, ["93930d65435d49db723499335473920795e7f13c45600dcfad922135cf44bd63"]) + } + + func testDecodeRecommendServer() throws { + + let event: RecommendServerEvent = try decodeFixture(filename: "recommend_server") + + XCTAssertEqual(event.id, "test-id") + XCTAssertEqual(event.pubkey, "test-pubkey") + XCTAssertEqual(event.createdAt, 1683799330) + XCTAssertEqual(event.kind, .recommendServer) + XCTAssertEqual(event.tags, []) + XCTAssertEqual(event.content, "wss://nostr.relay") + XCTAssertEqual(event.signature, "test-signature") + + XCTAssertEqual(event.relayURL, URL(string: "wss://nostr.relay")) } func testDecodeContactList() throws { diff --git a/Tests/NostrSDKTests/Fixtures/recommend_server.json b/Tests/NostrSDKTests/Fixtures/recommend_server.json new file mode 100644 index 0000000..156ce3f --- /dev/null +++ b/Tests/NostrSDKTests/Fixtures/recommend_server.json @@ -0,0 +1,9 @@ +{ + "content": "wss://nostr.relay", + "created_at": 1683799330, + "id": "test-id", + "kind": 2, + "pubkey": "test-pubkey", + "sig": "test-signature", + "tags": [] +}