Skip to content

A 100% type safe API to the x-callback-url scheme.

License

Notifications You must be signed in to change notification settings

ValentinWalter/Middleman

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

26 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

⭐️ Featured on the official x-callback-url.com blog

πŸ‘€ Middleman

A 100% type-safe API to the x-callback-url scheme.

πŸ” Overview

Suppose we want to build this x-callback-url in Middleman:

target://x-callback-url/do-something?
    key=value&
    x-success=source://x-callback-url/success?
        something=thing&
    x-error=source://x-callback-url/error?
        errorCode=404&
        errorMessage=message

We first declare the App called "Target". Target's only purpose is to provide the url-scheme://. It also makes a good namespace for all your actions. An Action is comprised of two nested types: Input and Output, which must conform to Codable. There are further customization option that you will learn about later.
To run the action, you call run(action:with:then:) on the App. run wants to know the Action to run, the Input of that action and a closure that is called with a Response<Output> once a callback is registered.

struct Target: App {
    struct DoSomething: Action {
        struct Input: Codable {
            let key: Value
            let optional: Value?
            let default: Value? = nil
        }
        
        struct Output: Codable {
            let something: Thing
        }
    }
}

// Running the action
Target().run(
    action: DoSomething(),
    with: .init(
        key: value,
        optional: nil
    ),
    then: { response in
        switch response {
        case let .success(output):
            print(output?.something)
        case let .error(code, msg):
            print(code, msg)
        case .cancel:
            print("canceled")
        }
    }
)

Next steps

  • Overhaul the receiving-urls-API so Middleman can be used to maintain x-callback APIs, not just work with existing ones
  • Implement a command-line interface using apple/swift-argument-parser
  • Migrate from callbacks to async in Swift 6

Examples

  • 🍯 Honey uses Middleman to provide a swifty API for Bear's x-callback-url API
  • File a pull request to include your own project!

πŸ›  Setup

If you want to receive callbacks you need to make sure your app has a custom url scheme implemented. Middleman will then read the first entry in the CFBundleURLTypes array in the main bundle's Info.plist. You can also manually define a url scheme.

Receiving urls

For Middleman to be able to parse incoming urls, you need to put one of the following methods in the delegate (UIKit/Cocoa) appropriate for your platform or in the onOpenURL SwiftUI modifier.

// SwiftUI
// On any view (maybe in your `App`)
.onOpenURL { url in
    Middleman.receive(url)
}

// macOS
// In your `NSAppDelegate`:
func application(_ application: NSApplication, open urls: [URL]) {
    Middleman.receive(urls)
}

// iOS 13 and up
// In your `UISceneDelegate`:
func scene(_ scene: UIScene, openURLContexts urlContexts: Set<UIOpenURLContext>) {
    Middleman.receive(urlContexts)
}

// iOS 12 and below
// In your `UIAppDelegate`:
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
    Middleman.receive(url)
}

Manually defining your url scheme

If Middleman's default behavior of reading from the Info.plist file does not work for you, you can manually define your url scheme. You do so by setting Middleman.receiver to your custom implementation.

struct MyApp: Receiver {
    var scheme: String { "my-scheme" }
}

// Then, notify Middleman of your custom implementation
Middleman.receiver = MyApp()

Installation

Middleman is a Swift Package. Write this in your Package.swift file:

let package = Package(
    ...
    dependencies: [
        .package(url: "https://github.com/ValentinWalter/middleman.git", from: "1.0.0")
    ],
    ...
)

πŸ‘Ύ API

Basic workflow

  • Define an Action, representing an x-callback-url action.
  • Define an App, which is responsible for sending and receiving actions.
  • Run actions via App.run(action:) with their Input associated type, optionally providing a closure that receives the Action's Output.

Actions

An action in Middleman represents an x-callback-url action. You create an action by conforming to the Action protocol. This requires you to define an Input and Output, which themselves require conformance to Codable. By default, Middleman will infer the path name of the action to be the kebab-case equivalent of the name of the Action type. In the example below, this would result in "open-note". You can overwrite this behavior by implementing the path property into your Action.

// Shortened version of Bear's /open-note action
struct OpenNote: Action {
    struct Input: Codable {
        var title: String
        var excludeTrashed: Bool
    }

    struct Output: Codable {
        var note: String
        var modificationDate: Date
    }
}

You can make handy use of typealias when it doesn't make sense to create your own type. Here we have an Action that takes a URL and has no output. Sometimes an Action doesn't have an Input or Output. In those cases, just typealias it to be Never and Middleman handles the rest.

struct OpenURL: Action {
    typealias Input = URL
    typealias Output = Never
}

Receiving Actions

You can implement the receive(input:) method in your Action to customize the behavior when the action was received by Middleman. Note that you also need to include your receiving action in your Receiver's receivingActions property. This API is in an alpha state (see next steps).

struct OpenBook: Action {
    ...
    func receive(input: Input) {
        // Handle opening book
    }
}

Apps and Receivers

Sending actions requires an App. You create one by conforming to the App protocol. Similarly to the Action protocol, Middleman infers the url-scheme of the app to be the kebab-case equivalent of the name of the conforming type. By default, the host property will be assumed to be "x-callback-url", as specified by the x-callback-url 1.0 DRAFT spec.

struct Bear: App {
    // By default, Middleman infers the two properties as implemented below
    var scheme: String { "bear" }
    var host: String { "x-callback-url" }
}

If your intent is to not only send, but receive actions, you define a Receiver, which inherits from the App protocol. This requires you to specify the actions with which your App can be opened. You then need to notify Middleman of your custom implementation, as described in Manually defining your url scheme. This API is in an alpha state (see next steps).

struct MyApp: Receiver {
    var receivingActions = [
        OpenBook().erased(),
        AnotherAction().erased()
    ]
}

Running an Action

Here's how running the above implementation of OpenNote would look.

Bear().run(
    action: OpenNote(),
    with: .init(
        title: "Title",
        excludeTrashed: true
    ),
    then: { response in
        switch response {
        case let .success(output): print(output?.note)
        case let .error(code, message): print(code, message)
        case .cancel: print("canceled")
        }
    }
)

In the case of an action having neither an Input or Output, you would have something like this:

SomeApp().run(
    action: SomeAction(),
    then: { response in
        switch response {
        case .success: print("success!")
        case .error(let code, let msg): print(code, msg)
        case .cancel: print("canceled")
        }
    }
)

🀝 Best Practices

It's a good idea to namespace your actions in an extension of their App. You can then also define static convenience functions, as calling the run method can get quite verbose. Following the OpenNote example from above:

extension Bear {
    // Namespaced declaration of the `OpenNote` action
    struct OpenNote { ... }

    // Static convenience function, making working with `OpenNote` more pleasant
    static func openNote(
        titled title: String,
        excludeTrashed: Bool = false,
        then callback: @escaping () -> Void
    ) {
        Bear().run(
            action: OpenNote(),
            with: .init(
                title: title,
                excludeTrashed: excludeTrashed
            ),
            then: { response in
                switch response {
                case .success(let output):
                    guard let output = output else { break }
                    callback(output.note)
                case .error: break
                case .cancel: break
                }
            }
        )
    }
}

// Opening a note is now as easy as
Bear.openNote(titled: "Title") { note in
    print("\(note) πŸ₯³")
}

🎭 Behind the scenes

Middleman uses a custom Decoder to go from raw URL to your Action.Output. Dispatched actions are stored with a UUID that Middleman inserts to each x-success/x-error/x-cancel parameter to match actions and their stored callbacks.