Skip to content

zealdin/stackmob-customcode-sdk

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

StackMob Custom Code SDK

Build Status

Why use the Custom Code SDK?

With StackMob's server-side custom code, you can write Java/Scala/Clojure code and upload it to StackMob. Using the StackMob mobile SDK's, you can trigger your code/logic on the server-side and process the returned JSON that you defined in your custom code. This means that you can not only quickly write your app on the mobile client side, but now you can also quickly write powerful code that runs server-side that can interact with your app!

StackMob already gives you datastore persistence and push services in the cloud. With server-side custom code, you can write a feature-rich app on a full featured platform with the power of server-side operations.

How does it work?

Write a simple Java/Scala/Clojure class implementing the CustomCodeMethod interface.

Here's a simple hello_world example. Upon uploading your JAR, StackMob will extend your method to your REST API at:

http://api.stackmob.com/hello_world

You can then call your code from the mobile iOS, Android, or JS SDKs (protected with OAuth of course! Our SDKs handle that). (Or anything else that can hit a REST API!)

The JSON that you define in your server-side code will be returned in the response.

Hello World - A Custom Code Example

Let's look at server-side code in Java/Scala. The following code will be found at method hello_world and will return the JSON { msg: 'hello world!' }

Java

HelloWorldExample.java:

package com.stackmob.example.helloworld;

import com.stackmob.core.customcode.CustomCodeMethod;
import com.stackmob.core.rest.ProcessedAPIRequest;
import com.stackmob.core.rest.ResponseToProcess;
import com.stackmob.sdkapi.SDKServiceProvider;
import java.net.HttpURLConnection;
import java.util.*;

public class HelloWorldExample implements CustomCodeMethod {

  /**
   * This method simply returns the name of your method that we'll expose over REST for
   * this class. Although this name can be anything you want, we recommend replacing the
   * camel case convention in your class name with underscores, as shown here.
   *
   * @return the name of the method that should be exposed over REST
   */
  @Override
  public String getMethodName() {
    return "hello_world";
  }

  /**
   * This method returns the parameters that your method should expect in its query string.
   * Here we are using no parameters, so we just return an empty list.
   *
   * @return a list of the parameters to expect for this REST method
   */
  @Override
  public List<String> getParams() {
    return Arrays.asList();
  }

  /**
   * This method contains the code that you want to execute.
   *
   * @return the response
   */
  @Override
  public ResponseToProcess execute(ProcessedAPIRequest request, SDKServiceProvider serviceProvider) {
    
    //Send push messages...
    //Query the datastore...
    //Run complex server side operations..
    
    //Then prepare your custom JSON to send back to the mobile client
    Map<String, String> args = new HashMap<String, String>();
    args.put("msg", "hello world!");
    return new ResponseToProcess(HttpURLConnection.HTTP_OK, args);
  }

}    

Scala

package com.stackmob.example.helloworld

import java.util.Arrays
import java.net.HttpURLConnection._
import com.stackmob.core.customcode.CustomCodeMethod
import com.stackmob.sdkapi._
import com.stackmob.core.rest.{ProcessedAPIRequest, ResponseToProcess}
import scala.collection.JavaConverters._

class HelloWorldExample extends CustomCodeMethod {

  /**
   * This method simply returns the name of your method that we'll expose over REST for
   * this class. Although this name can be anything you want, we recommend replacing the
   * camel case convention in your class name with underscores, as shown here.
   *
   * @return the name of the method that should be exposed over REST
   */
  override def getMethodName: String = {
    "hello_world"
  }

  /**
   * This method returns the parameters that your method should expect in its query string.
   * Here we are using no parameters, so we just return an empty list.
   *
   * @return a list of the parameters to expect for this REST method
   */
  override def getParams: java.util.List[String] = {
    Arrays.asList()
  }

  /**
   * This method contains the code that you want to execute.
   *
   * @return the response
   */
  override def execute(request: ProcessedAPIRequest, serviceProvider: SDKServiceProvider): ResponseToProcess = {
    new ResponseToProcess(HTTP_OK, Map("msg" -> "hello world!").asJava)
  }

}

Custom code allows you to even define the returned JSON. In this case, our simple Hello World example will return:

{ "msg": "Hello, world!" }

You can call your server-side custom code from your SDK. The request will be sent from the client, StackMob will route the call to the appropriate code and execute the code you've written, then StackMob will return the JSON you've defined.

Calling Server-Side Custom Code from the Mobile SDK

Let's see how client-side SDK code calls and interacts with the server-side custom code:

**iOS SDK**
SMCustomCodeRequest *request = [[SMCustomCodeRequest alloc] initGetRequestWithMethod:@"hello_world"];
         
[[[SMClient defaultClient] dataStore] performCustomCodeRequest:request 
  onSuccess:^(NSURLRequest *request, NSHTTPURLResponse *response, id JSON) {
        // result is the JSON as an NSDictionary of "msg" vs. "Hello, world!"
        NSLog(@"Success: %@",JSON);
  } onFailure:^(NSURLRequest *request, NSHTTPURLResponse *response, NSError *error, id JSON){
        NSLog(@"Failure: %@",error);
}];
**Android SDK**
StackMob.getStackMob().getDatastore().get("hello_world", new StackMobCallback() {
    @Override public void success(String responseBody) {
        //responseBody is "{ \"msg\": \"Hello, world!\" }"
    }
    @Override public void failure(StackMobException e) {
    }
});
**JS SDK**
<script type="text/javascript">
  StackMob.customcode('hello_world', {}, {
     success: function(jsonResult) {
       //jsonResult is the JSON object: { "msg": "Hello, world!" }
     },
     
     error: function(failure) {
       //doh!
     }
  });
</script>

You've just seen how you can write server-side code and call it from your mobile application, powerfully extending your ability to build a full featured app.

QuickStart - Fork Example Custom Code on GitHub

To get you started, we've actually provided an example Custom Code example on GitHub for you. Feel free to fork it. From there you have two choices:

Fork the Custom Code Example on GitHub

You can feel free to add your own classes and use this as a template from which to build.

Building Custom Code from Scratch

If you'd rather build a custom code project from scratch, read on.

Including the Custom Code SDK in your Project

StackMob provides various ways for you to include the Custom Code SDK in your project. You may choose according to your preference.

  • Maven (recommended)
  • sbt (also recommended if you're using Scala)
  • Download and manually include the JAR (not recommended)

JAR

Latest version: 0.5.3

Download the latest JAR.

Maven

<dependency>
  <groupId>com.stackmob</groupId>
  <artifactId>customcode</artifactId>
  <version>0.5.3</version>
  <scope>provided</scope>
</dependency>

Also, you can see what an example pom.xml file would look like for your project here.

sbt - Simple Build Tool

libraryDependencies += "com.stackmob" % "customcode" % "0.5.3" % "provided"

In our custom code example repo you'll find a sample scala-sbt project with the file build.sbt. For those not familiar with sbt, here is the Getting Started with sbt

Register your Method

StackMob needs to know where to find your defined methods. Register them in the EntryPointExtender and include it in your JAR.

Java

EntryPointExtender.java:

package com.stackmob.example.helloworld;

import com.stackmob.core.jar.JarEntryObject;
import com.stackmob.core.customcode.CustomCodeMethod;
import com.stackmob.example.helloworld.HelloWorldExample;
import java.util.List;
import java.util.ArrayList;

public class EntryPointExtender extends JarEntryObject {

  @Override
  public List<CustomCodeMethod> methods() {
    List<CustomCodeMethod> list = new ArrayList<CustomCodeMethod>();
    list.add(new HelloWorldExample());
    return list;
  }

}

Scala

package com.stackmob.example.helloworld

import com.stackmob.core.customcode.CustomCodeMethod
import com.stackmob.core.jar.JarEntryObject

class EntryPointExtender extends JarEntryObject {

  override def methods: java.util.List[CustomCodeMethod] = {
    Arrays.asList(new HelloWorldExample)
  }
    
}

Define the JAR Manifest

StackMob requires that your custom code JAR have a manifest with the Main-Class attribute defined. The main class must extend JarEntryObject.

Maven

Add this to the pom.xml file, in the build plugins section:

<build>
  ...
  <plugins>
    ...
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-jar-plugin</artifactId>
      <configuration>
        <archive>
          <manifest>
            <mainClass>com.stackmob.example.helloworld.EntryPointExtender</mainClass>
          </manifest>
        </archive>
      </configuration>
    </plugin>
  </plugins>
</build>

Then go to the pom.xml directory and type:

> mvn package

Your jar should be inside target folder.

Ant

Add this to the build.xml file:

<jar destfile="${dist}/target/HelloWorldExample.jar" basedir="${build}/classes" excludes="**Tests.class">
  <manifest>
    <attribute name="Main-Class" value="com.stackmob.example.helloworld.EntryPointExtender"/>
  </manifest>
</jar>

sbt

Customize the build.sbt file for your project.

...

packageOptions in (Compile, packageBin) += 
  Package.ManifestAttributes( java.util.jar.Attributes.Name.MAIN_CLASS -> "com.stackmob.example.EntryPointExtender" )

In the terminal, go to your project directory and type sbt clean package

Your JAR is located in /target/scala-2.9.1. Now upload the JAR to StackMob.

Manual

Again, we recommend against this approach, but if you must:

Create a file called JarManifest with nothing but this line in it:

Main-Class: com.stackmob.example.helloworld.EntryPointExtender

Then, in the same directory as that file, execute these three commands on the command line:

> mkdir -p target/classes
> javac -d target/classes -classpath CLASSPATH SOURCE_FILES_SPACE_DELIMITED
> jar cfm HelloWorldExample.jar JarManifest -C target/classes com/stackmob/example/helloworld/*.class

After the JAR is built, let's do a quick sanity check. First, unzip the JAR into a new directory and change to that directory:

> unzip HelloWorldExample.jar -d JarUnzipped
> cd JarUnzipped

Then, ensure that the Manifest is correct:

> cd META-INF
> cat MANIFEST.MF | grep Main-Class

The output of the last command should look like this:

Main-Class: com.stackmob.example.helloworld.EntryPointExtender

Now, let's make sure all the classes were packaged correctly:

> cd ..
> ls -la com/stackmob/example/helloworld

If the output of the ls command showed EntryPointExtender.class, then your JAR is correct and ready to upload and the unzipped JAR contents can be deleted:

> cd ..
> rm -rf JarUnzipped

Uploading your JAR to StackMob

Once you have your custom methods written, package it as a JAR so that it can be uploaded to StackMob. Upon uploading, StackMob will immediately process and roll out the code to your application's sandbox environment. To test your new REST API method, use the console.

Fetch Parameters

Each parameter returned in the getParams method of a custom code method can be accessed at runtime via:

Java

public ResponseToProcess execute(ProcessedAPIRequest request, SDKServiceProvider serviceProvider) {
  String data = request.getParams().get("my_param");
  ...
}

Scala

override def execute(request: ProcessedAPIRequest, serviceProvider: SDKServiceProvider): ResponseToProcess = {
  val data = request.getParams.get("timeout_ms")
  ...
}

Interact with the datastore

The datastore service provides server-side access to the StackMob datastore and can be used to create REST API extensions.

The example below shows how to set a high score on a user model via the URL:

URL:
http://api.stackmob.com/set_high_score?username=user_10&score=12345.

Request Headers:
Accept: application/vnd.stackmob+json; version=0

Note, this example only shows the execute method of a CustomCodeMethod subclass.

Java

@Override
public ResponseToProcess execute(ProcessedAPIRequest request, SDKServiceProvider serviceProvider) {
  String username = request.getParams().get("username");
  Long score = Long.parseLong(request.getParams().get("score"));

  if (username == null || username.isEmpty() || score == null) {
    HashMap<String, String> errParams = new HashMap<String, String>();
    errParams.put("error", "one or both the username or score was empty or null");
    return new ResponseToProcess(HttpURLConnection.HTTP_BAD_REQUEST, errParams); // http 400 - bad request
  }

  // get the datastore service and assemble the query
  DataService dataService = serviceProvider.getDataService();

  // build a query
  List<SMCondition> query = new ArrayList<SMCondition>();
  query.add(new SMEquals("username", new SMString(username)));

  // execute the query
  List<SMObject> result;
  try {
    boolean newUser = false;
    boolean updated = false;

    result = dataService.readObjects("users", query);

    SMObject userObject;

    // user was in the datastore, so check the score and update if necessary
    if (result != null && result.size() == 1) {
      userObject = result.get(0);
    } else {
      Map<String, SMValue> userMap = new HashMap<String, SMValue>();
      userMap.put("username", new SMString(username));
      userMap.put("score", new SMInt(0L);
      newUser = true;
      userObject = new SMObject(userMap);
    }

    SMValue oldScore = userObject.getValue().get("score");

    // if it was a high score, update the datastore
    List<SMUpdate> update = new ArrayList<SMUpdate>();
    if (oldScore == null || ((SMInt)oldScore).getValue() < score) {
      update.add(new SMSet("score", new SMInt(score)));
      updated = true;
    }

    if(newUser) {
      dataService.createObject("users", userObject);
    } else if(updated) {
      dataService.updateObject("users", username, update);
    }

    Map<String, Object> returnMap = new HashMap<String, Object>();
    returnMap.put("updated", updated);
    returnMap.put("newUser", newUser);
    returnMap.put("username", username); 
    return new ResponseToProcess(HttpURLConnection.HTTP_OK, returnMap);
  } catch (InvalidSchemaException e) {
    HashMap<String, String> errMap = new HashMap<String, String>();
    errMap.put("error", "invalid_schema");
    errMap.put("detail", e.toString());
    return new ResponseToProcess(HttpURLConnection.HTTP_INTERNAL_ERROR, errMap); // http 500 - internal server error
  } catch (DatastoreException e) {
    HashMap<String, String> errMap = new HashMap<String, String>();
    errMap.put("error", "datastore_exception");
    errMap.put("detail", e.toString());
    return new ResponseToProcess(HttpURLConnection.HTTP_INTERNAL_ERROR, errMap); // http 500 - internal server error
  } catch(Exception e) {
    HashMap<String, String> errMap = new HashMap<String, String>();
    errMap.put("error", "unknown");
    errMap.put("detail", e.toString());
    return new ResponseToProcess(HttpURLConnection.HTTP_INTERNAL_ERROR, errMap); // http 500 - internal server error
  }

}

Scala

def execute(request: ProcessedAPIRequest, serviceProvider: SDKServiceProvider): ResponseToProcess = {
  val username = request.getParams.get("username")
  val score = request.getParams.get("score").toLong

  if (username == null || username.isEmpty || score == null) {  // http 400 - bad request
    return new ResponseToProcess(HTTP_BAD_REQUEST, Map("error" -> "one or both the username or score was empty or null").asJava)
  }

  // get the datastore service and assemble the query
  val dataService: DataService = serviceProvider.getDataService
  val query = List[SMCondition](new SMEquals("username", new SMString(username)))

  try {
    //execute the query
    val result = dataService.readObjects("users", query.asJava)

    // check if the user is in the datastore
    val (userObject, newUser) = result match {
      case (userObj: SMObject) :: Nil => (userObj,  false)
      case _ => (new SMObject(Map[String, SMValue[_]]("username" -> new SMString(username)).asJava), true)
    }

    // if it's a new high score, the database needs to be updated
    val updated = userObject.getValue.get("score") match {
      case s: SMInt if s.getValue < score => true
      case _ => false
    }

    if (newUser) {
      dataService.createObject("users", new SMObject((userObject.getValue.asScala + ("score" -> new SMInt(score))).asJava))
    } else if (updated) {
      dataService.updateObject("users", username, Arrays.asList(new SMSet("score", new SMInt(score))))
    }

    new ResponseToProcess(HTTP_OK, Map("updated" -> updated, "newUser" -> newUser, "username" -> username).asJava) // http 200 - ok
  } catch { // http 500 - internal server error
    case e: InvalidSchemaException => new ResponseToProcess(HTTP_INTERNAL_ERROR , Map("error" -> "invalid_schema", "detail" -> e.toString).asJava)
    case e: DatastoreException => new ResponseToProcess(HTTP_INTERNAL_ERROR , Map("error" -> "datastore_exception", "detail" -> e.toString).asJava)
    case e => new ResponseToProcess(HTTP_INTERNAL_ERROR , Map("error" -> "unknown", "detail" -> e.toString).asJava)
  }
}

Interacting with your Logged-In Users

The SDK allows you to check for a currently logged-in user, direct from the ProcessedAPIRequest. Here's how:

Java

@Override
public ResponseToProcess execute(ProcessedAPIRequest request, SDKServiceProvider serviceProvider) {
  String username = request.getLoggedInUser();

  if (username == null || username.isEmpty()) {
    HashMap<String, String> errParams = new HashMap<String, String>();
    errParams.put("error", "no user is logged in");
    return new ResponseToProcess(HttpURLConnection.HTTP_UNAUTHORIZED, errParams); // http 401 - unauthorized
  }

  Map<String, Object> returnMap = new HashMap<String, Object>();
  returnMap.put("currentLogin", username);
  return new ResponseToProcess(HttpURLConnection.HTTP_OK, returnMap);
}

Scala

override def execute(request: ProcessedAPIRequest, serviceProvider: SDKServiceProvider): ResponseToProcess = {
  val username = request.getLoggedInUser

  if (username == null || username.isEmpty) {
    return new ResponseToProcess(HTTP_UNAUTHORIZED, Map("error" -> "no user is logged in").asJava)
  }

  new ResponseToProcess(HTTP_OK, Map("currentLogin" -> username).asJava) // http 200 - ok
}

Push Notifications

The SDK gives access to the StackMob Push Notification service through the PushService class. Here's how to use it:

Java

@Override
public ResponseToProcess execute(ProcessedAPIRequest request, SDKServiceProvider serviceProvider) {
  PushService pushService = serviceProvider.getPushService();

  //register iOS token for John Doe
  TokenAndType iOSToken = new TokenAndType("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", TokenType.iOS);
  pushService.registerTokenForUser("JohnDoe", iosToken);

  //register Android token for John Doe
  TokenAndType androidToken = new TokenAndType("androidtoken", TokenType.Android);
  pushService.registerTokenForUser("JohnDoe", androidToken);

  //get all tokens for John Doe
  List<String> users = new ArrayList<String>();
  users.add("JohnDoe");
  Map<String, List<TokenAndType>> tokensForJohnDoe = pushService.getAllTokensForUsers(users);

  //send a push notification just to John Doe's iOS device
  List<TokenAndType> tokensToSendTo = new ArrayList<TokenAndType>();
  tokensToSendTo.add(iosToken);
  Map<String, String> payload = new HashMap<String, String>();
  payload.put("badge", "1");
  payload.put("sound", "customsound.wav");
  payload.put("alert", "Hello from Stackmob!");
  payload.put("other", "stuff");
  pushService.sendPushToTokens(tokensToSendTo, payload);

  //send a push notification to all of John Doe's devices
  pushService.sendPushToUsers(users, payload);

  //broadcast a push notification to EVERYONE - use carefully!
  pushService.broadcastPush(payload);

  //remove the iOS token for John Doe
  pushService.removeToken(iosToken);
  
  Map<String, Object> map = new HashMap<String, Object>();
  map.put("status", "ok");
  return new ResponseToProcess(HttpURLConnection.HTTP_OK, map);
}

Scala

override def execute(request:ProcessedAPIRequest, serviceProvider:SDKServiceProvider): ResponseToProcess = {
  val pushService = serviceProvider.getPushService

  //register iOS token for John Doe
  val iOSToken = new TokenAndType("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", TokenType.iOS);
  val androidToken = new TokenAndType("androidtoken", TokenType.Android)
  pushService.registerTokenForUser("JohnDoe", androidToken);

  //get all tokens for John Doe
  val users:List[String] = List("JohnDoe")
  val tokensForJohnDoe = pushService.getAllTokensForUsers(users.asJava)

  //send a push notification just to John Doe's iOS device
  val tokensToSendTo:List[TokenAndType] = List(iOSToken)
  val payload: Map[String, String] = Map("badge" -> "1",
    "sound" -> "customsound.wav",
    "alert" -> "Hello from Stackmob!",
    "other" -> "stuff")
  pushService.sendPushToTokens(tokensToSendTo.asJava, payload.asJava)

  //send a push notification to all of John Doe's devices
  pushService.sendPushToUsers(users.asJava, payload.asJava)

  //broadcast a push notification to EVERYONE - use carefully!
  pushService.broadcastPush(payload.asJava)

  //remove the iOS token for John Doe
  pushService.removeToken(iOSToken)

  new ResponseToProcess(HTTP_OK, Map("status" -> "ok").asJava)
}

Logging

The logger service provided by the SDK should be used to log information from within your custom code. Anything logged via the logger service will be accessible via StackMob's web platform.

Java

@Override
public ResponseToProcess execute(ProcessedAPIRequest request, SDKServiceProvider provider) {
  LoggerService logger = provider.getLoggerService(MyCustomCodeMethod.class);

  logger.debug("log anything");

  try {
    throw new NullPointerException("some npe");
  } catch (NullPointerException e) {
    logger.error(e.getMessage(), e);
  }

  Map<String, Object> map = new HashMap<String, Object>();
  map.put("status", "ok");
  return new ResponseToProcess(HttpURLConnection.HTTP_OK, map);
}

Scala

override def execute(request: ProcessedAPIRequest, sdk: SDKServiceProvider): ResponseToProcess = {
  val logger = sdk.getLoggerService(classOf[MyCustomCodeMethod])

  logger.debug("log anything")

  try {
    throw new NullPointerException("some npe")
  } catch {
    case e: NullPointerException => {
      logger.error(e.getMessage, e)
    }
  }

  new ResponseToProcess(HTTP_OK, Map("status" -> "ok").asJava)
}

Making HTTP Requests

The SDK includes functionality in an HttpService to make a limited number of HTTP GET, POST, PUT and DELETE requests to most HTTP servers. Behind the scenes, StackMob rate limits the number of HTTP calls an application can make, as well as to which domains it cannot make HTTP requests (ie: a blacklist).

The rate limits and the blacklist are such that you can expect reasonable HTTP calls to succeed normally, but if StackMob does rate limit or blacklist an HTTP call, you can expect that HttpService will throw an AccessDeniedException.

Java

@Override
public ResponseToProcess execute(ProcessedAPIRequest request, SDKServiceProvider provider) {
	HttpService http = provider.getHttpService();
	//create the HTTP request
	String url = "http://stackmob.com";
	GetRequest req = new GetRequest(url);
	//send the request. this method call will not return until the server at http://stackmob.com returns.
	//note that this method may throw AccessDeniedException if the URL is whitelisted or rate limited,
	//or TimeoutException if the server took too long to return
	HttpResponse resp = http.get(req);
	Map<String, Object> map = new HashMap<String, Object>();
	map.put("response_code", resp.getCode());
	map.put("url", url);
	return new ResponseToProcess(HttpURLConnection.HTTP_OK, map);
}

Scala

// Make sure to import these
import com.stackmob.core.customcode.CustomCodeMethod
import com.stackmob.sdkapi.SDKServiceProvider
import com.stackmob.sdkapi.http.request.GetRequest
import com.stackmob.core.rest.{ResponseToProcess, ProcessedAPIRequest}
import java.net.HttpURLConnection
import scala.collection.JavaConverters._

...

override def execute(request: ProcessedAPIRequest, serviceProvider: SDKServiceProvider): ResponseToProcess = {
	val http = serviceProvider.getHttpService
	val url = "http://stackmob.com"
	//create the HTTP request
	val getReq = new GetRequest(url)
	//send the request. this method call will not return until the server at http://stackmob.com returns.
	//note that this method may throw AccessDeniedException if the URL is whitelisted or rate limited,
	//or TimeoutException if the server took too long to return
	val resp = http.get(getReq)
	val map = Map("response_code" -> resp.getCode, "url" -> url)
	new ResponseToProcess(HttpURLConnection.HTTP_OK, map.asJava)
}

Release Notes

Release notes are available here.

Javadocs

Javadocs are available here.

Copyright

Copyright 2012 StackMob

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

About

StackMob Custom Code SDK

Resources

License

Stars

Watchers

Forks

Packages

No packages published