Jasonette / JASONETTE-iOS

📡 Native App over HTTP, on iOS
https://www.jasonette.com
MIT License
5.26k stars 352 forks source link

Support Events as actions #57

Open seletz opened 7 years ago

seletz commented 7 years ago

Motivation

So it looks like the plug-in approach #26 will use notifications to communicate with the core. This opens new possibilities for integrations. For example, issue #49 is about web socket support. Such a feature can live in a plug-in w/o problems.

However, web sockets are async in nature, that is, they can receive messages at any time. How would users be able to react on those events?

Note: I use WebSocket as an example here. This is obviously useful for catching other events and could replace the hard-coded "system events" in Jasonette.

Proposal

I propose a new action type for events. Those actions would "register" itself for a notification handler by name:

{
 ...
    {
        // register hypothetical web socket
        "type": "@JasonetteWebSocketPlugin.connect",
        "options": {
             "url": "wss://example.com/ws/echo"
        }
   }
...
   // register for events 
   {
       "type": "!WebSocket.message",
       "success": {
           // do whatever you want here.  Will be called on the 'WebSocket.message'
           // notification sent by the WebSocket plugin if messages arrive 
       }
   },
...
}

Details

For the above to work, the hypothetical WebSocket plugin would send a notification on received messages with name WebSocket.message.

The !WebSocket.message action would instruct the core to register a notification handler for the WebSocket.message notification and do a [[Jason client] success:notification.object] if the notification is received. This can be nicely done in a block.

All this could be done in a plug-in also. But this ia s feature which is core-worthy IMHO 😄

Problems

gliechtenstein commented 7 years ago

@seletz Just want to clarify first, with your example I'm thinking you were envisioning something like this?

{
  "$jason": {
    "head": {
      "actions": {
        "$load": {
          "type": "@JasonetteWebSocketPlugin.connect",
          "options": {
            "url": "wss://example.com/ws/echo"
          }
        },
        "!WebSocket.message": {
          "type": "$set",
          "options": {
            "messages": "{{var messages = $get.messages; messages.push($jason); return messages;}}"
          },
          "success": {
            "type": "$render"
          }
        }
      }
    }
  }
}

I think it's best if we walk through using the new notification based action extension code

1. Add call handler to core

First, we could add a Jason.call notification and a notifyCall: method.

- (id)init {
    if (self = [super init]) {
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onForeground) name:UIApplicationDidBecomeActiveNotification object:nil];
        self.searchMode = NO;

        // Add observers for public API
        [[NSNotificationCenter defaultCenter]
                addObserver:self
                   selector:@selector(notifySuccess:)
                       name:@"Jason.success"
                     object:nil];

        [[NSNotificationCenter defaultCenter]
            addObserver:self
               selector:@selector(notifyError:)
                   name:@"Jason.error"
                 object:nil];

        [[NSNotificationCenter defaultCenter]
            addObserver:self
               selector:@selector(notifyCall:)
                   name:@"Jason.call"
                 object:nil];

    }
    return self;
}

#pragma mark - Jason Core API Notifications
- (void)notifySuccess:(NSNotification *)notification {
    NSDictionary *args = notification.object;
    NSLog(@"JasonCore: notifySuccess: %@", args);
    [[Jason client] success:args];
}

- (void)notifyError:(NSNotification *)notification {
    NSDictionary *args = notification.object;
    NSLog(@"JasonCore: notifyError: %@", args);
    [[Jason client] error:args];
}

- (void)notifyCall:(NSNotification *)notification {
    NSDictionary *args = notification.object;
    NSLog(@"JasonCore: notifyCall: %@", args);
    [[Jason client] call:args];
}

2. Extension code

Then, from the extension side we can add another method (which will be triggered automatically by another method in the extension class, for example a new message from Websocket could call this method) which will then post a Jason.call notification with a trigger, along with the payload as options, like this:

- (void)newMessage:(NSDictionary *)message {
    NSDictionary *args = notification.userInfo;
    NSDictionary *options = args[@"options"];

    NSLog(@"JasonetteDemoAction: text: %@", message);
    [[NSNotificationCenter defaultCenter]
            postNotificationName:@"Jason.call"
                          object:self
                        userInfo:@{
                          @"trigger": @"!WebSocket.message",
                          @"options": {@"message": message}
                        }];
}

This will kick off a new action call chain that looks like this:

{
  "trigger": "!WebSocket.message",
  "options": {
    "message": "..."
  }
}

By the way, we already have an event registry tied to the view controller: VC.events, and trigger is already implemented in a way that it can accept an options object and pass it along to the resolved action after lookup, so it should work out of the box without further complication.

3. Teardown

Lastly, we have a detach: method which takes care of cleanup on viewWillDisappear: events. So I think we can just place a post notification line in there some where, like this:

    [[NSNotificationCenter defaultCenter]
            postNotificationName:@"Jason.kill"
                          object:self
                        userInfo:nil];

and go back to the extension code and add something like this:

        [[NSNotificationCenter defaultCenter]
            addObserver: self
               selector:@selector(kill:)
                   name:@"Jason.kill"
                 object:nil];

...

- (void)kill{
        [[NSNotificationCenter defaultCenter]
            removeObserver:self
            name:@"!WebSocket.message"
            object:nil];
}

I haven't yet tried this and it just came straight from head so there may be some mistakes, but theoretically I think this should work...so let me know if I'm missing something.

I couldn't actually try it because I was waiting for your pull request haha

gliechtenstein commented 7 years ago

Just realized this may work for some cases, but for cases like websockets I think we may need some sort of a "module holder" so that these daemons associated with the actions don't get garbage collected after execution.. Was this what you meant by registry?

seletz commented 7 years ago

Please see PR #62

As for your comments -- I think I need more time to think them through. I'll follow up here.

gliechtenstein commented 7 years ago

Was going to sit on this for a bit, but ran into the same architecture problem with the Android side I was working on tonight, so wrote the logic on Android first, and ported it back to iOS for discussion https://github.com/Jasonette/JASONETTE-iOS/pull/63

It covers everything I mentioned above:

  1. Implement call notification endpoint for Jason
  2. Attach all module instances to the viewcontroller so they stick around

Further explanation

Better explained with an example. We want to do something like this:

{
  "$jason": {
    "head": {
      "actions": {
        "$load": {
          "type": "$util.alert",
          "options": {
            "title": "Enter username",
            "description": "Enter username to join the chatroom",
            "form": [{
              "name": "username"
            }]
          },
          "success": {          
            "type": "@websockets.connect",
            "options": {
              "url": "ws://chatroom.chatroom/chatroom",
              "data": {
                "username": "{{$jason.username}}"
              }
            }
          }
        },
        "!websockets.message": {
          "type": "$set",
          "options": {
            "messages": "{{ var buffer = $get.messages; buffer.push($jason); return buffer }}"
          },
          "success": {
            "type": "$render"
          }
        },
        "!websockets.connected": {
          "type": "$util.banner",
          "options": {
            "title": "Connected",
            "description": "Successfully connected to localhost"
          }
        }
      }
    }
  }
}

First of all, for all this to work, the JasonWebsocketsAction should stick around after the @websockets.connect action execution. Currently that's not possible since the module we use to invoke actions are not designed to stick around. They're currently more like a one-off thing that does its job and goes away. The module itself gets freshly instantiated every time it needs to execute

That's why I've added an NSMutableDictionary type modules to JasonViewController, as sort of a modules registry.

Then when there's a new message from the websockets connection, JasonWebsocketsAction will need to notify Jason core. To do that, we may have a code inside JasonWebsocketsAction that looks like this:

    [[NSNotificationCenter defaultCenter]
            postNotificationName:@"Jason.call"
                          object:self
                        userInfo:@{
                          @"trigger": @"!websockets.message",
                          @"options": {@"message": message}
                        }];
}

Then naturally it will kick off the action call chain. No need for additional logic. Basically Jason core functions as the action dispatch backbone.

seletz commented 7 years ago

@gliechtenstein WRT the registry -- we could just do away with that by demanding that Event-enabled plug-ins must act as singletons. That way, nothing needs to be changed and plug-ins can stay very easy, on-demand things.

What's not immediately clear to me is how a plug-in would react to events, i.e. how would plug-ins be able to register their own handlers?

Im my original idea it would look something like this (beware, out-of-my head and not even syntax tested):

@implementation FooEventEnabledPlugin

+(id)sharedInstance() { do the magic singleton dance here}

- (id)init {
    // if we have a instance, return that one here, else

   // create plugin instance
    if (self = [super init]) {

        [[NSNotificationCenter defaultCenter]
         addObserver:self
         selector:@selector(handleAction:)
         name:@"FooEventEnabledPlugin.action"
         object:nil];

    }
    return self;
}

-(void)handleAction:(NSNotification *)notification {
    NSDictionary *options = [self optionsFromNotification:notification];

    if (options) {

       // do some work
      [someApi doWork:options completion:^(NSDictionary result) {
            // got some async result.
            // We'll post a new event just in case someone listens. 
            [[NSNotificationCenter defaultCenter]
                postNotificationName:@"!FooEventEnabledPlugin.asyncResult"
                         object:self
                         userInfo:@{
                          @"options": result
                        }];    

      }];

      //report success
    [[NSNotificationCenter defaultCenter]
            postNotificationName:@"Jason.ok"
                         object:self
                        userInfo:@{
                          @"options": {@"foo": 42}
                        }];    
    }
}

@end

The above plug-in would be used like:

{
    ...
    {
       "type": "@FooPluginEventEnabled.action"
       "options": { ... }
       "success": { ... here we get the 42 result from the action ... }
    }
   ...
   {
     "type": "!FooPluginEventEnabled.asyncResult"
     "options": {}
     "success": {  here we'd get the result form the async operation }
   }
}

It's not clear to me how the above use case would work with your suggestion -- but maybe I'm missing something.

gliechtenstein commented 7 years ago

As for the singleton idea,

initially I wanted to tie it to the viewcontroller because that way they will automatically go away when the view closes. The reason I was thinking about it this way was because I was trying to make the websockets case work without making too much change to the existing architecture. Basically I was approaching this from a web browser metaphor, where any actions that were running gets reset when a user refreshes or moves away to another page.

But upon thinking about your comment I think I can understand what you're saying. But just to clarify, do we need singletons (vs. tying them under viewcontrollers) because that will enable actions to exist across views? If so, can you give some examples? That would help us get on the same page.

Regarding the "how would plug-ins be able to register their own handlers?" question:

I feel like I am seriously misunderstanding your idea. I think it would help if you can provide full context to your example JSON:

{
    ...
    {
       "type": "@FooPluginEventEnabled.action"
       "options": { ... }
       "success": { ... here we get the 42 result from the action ... }
    }
   ...
   {
     "type": "!FooPluginEventEnabled.asyncResult"
     "options": {}
     "success": {  here we'd get the result form the async operation }
   }
}

More specifically, is this under $jason.head.actions like usual? or Is this a completely new way of describing actions? I ask because if it's under $jason.head.actions, these actions should have a name as their keys (which are basically like events, and can be triggered using the trigger syntax).

Thanks!

gliechtenstein commented 7 years ago

Actually, please ping me on slack when you see this, that would be better than going back and forth async