Skip to content

Commit

Permalink
establish NostrEvent subclassing pattern with basic event kinds (#56)
Browse files Browse the repository at this point in the history
* establish NostrEvent subclassing pattern with basic event kinds

Closes #48
Closes #49
Closes #50
Closes #51

* lint

* validate relay URLs

* add external interface for creating set metadata and recommend server events, plus tests
  • Loading branch information
bryanmontz authored Jul 25, 2023
1 parent bf0a080 commit 4da2bbc
Show file tree
Hide file tree
Showing 9 changed files with 274 additions and 10 deletions.
48 changes: 42 additions & 6 deletions Sources/NostrSDK/EventCreating.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions Sources/NostrSDK/Events/RecommendServerEvent.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
71 changes: 71 additions & 0 deletions Sources/NostrSDK/Events/SetMetadataEvent.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
26 changes: 26 additions & 0 deletions Sources/NostrSDK/Events/TextNoteEvent.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
29 changes: 28 additions & 1 deletion Sources/NostrSDK/RelayResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions Tests/NostrSDKTests/EventCreatingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: "[email protected]",
pictureURL: URL(string: "[email protected]"),
bannerPictureURL: URL(string: "[email protected]")!)

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, "[email protected]")
XCTAssertEqual(event.userMetadata?.pictureURL, URL(string: "[email protected]"))
XCTAssertEqual(event.userMetadata?.bannerPictureURL, URL(string: "[email protected]"))

try verifyEvent(event)
}

func testCreateSignedTextNote() throws {
let note = try textNote(withContent: "Hello world!",
signedBy: Keypair.test)
Expand All @@ -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))
}
}
39 changes: 37 additions & 2 deletions Tests/NostrSDKTests/EventDecodingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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, "[email protected]")
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, "[email protected]")
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")
Expand All @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions Tests/NostrSDKTests/Fixtures/recommend_server.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"content": "wss://nostr.relay",
"created_at": 1683799330,
"id": "test-id",
"kind": 2,
"pubkey": "test-pubkey",
"sig": "test-signature",
"tags": []
}

0 comments on commit 4da2bbc

Please sign in to comment.