Tutorial 5 - Adding multi-user support

Your event app currently allows you to create new events that store relevant data in separate fields, edit this data in existing events, as well as delete events.

However, the app currently provides no support for different users to create and manage their own events.

In this tutorial, you will extend the existing blueprint and event app functionality (utilising the JS SDK) to add multi-user support, along with the ability to connect individual users to their own events.

This tutorial takes around 40-50 minutes to complete.

Blueprint design implementation approach

There are two possible approaches to implementing the blueprint design for multi-user support:

  1. Store a user ID as an additional property value (such as createdBy) in an event document.

    • The events collection of event documents can then be filtered by the value of this new property value.

    • Use this approach if you already have an existing API specification (e.g. /events/{eventid}) currently in production, as this requires minimal changes to the specification and provides backwards compatibility.

  2. Restructure the API specification to include a root level users collection containing {userid} documents, each of which contains an events collection containing {eventid} (event) documents
    (i.e. /users/{userid}/events/{eventid}).

    • This structural change associates multiple events with a specific user (i.e. the user’s own events), which achieves the same result as obtaining a collection of events filtered by a specific user (above).

    • However, the ability to extract events for a single user (i.e. without filtering) is a much faster Datastore operation than requesting a filtered list. Therefore, if listing events by a given user is the primary concern of your app, choosing a document and collection structure like this (maximising performance) is the best option.

Since the event app you are building is not being used in production, this tutorial will utilise the second approach.

If you wanted multiple users to be able to access a given event, then a modified version of the first approach would be the best to implement.

An example might be to implement an array property on an event document (such as accessibleBy), to which multiple user IDs can be set. Filtering with a matching user ID can then be performed on this property to retrieve all event documents that the user has access to.

Define properties for user documents

Before defining the structure and methods for user documents in the API specification, define the following properties for this new document type:

  • A Datastore extension property called userid, which stores the value of the user document’s own internal ID. Read more about this Datastore extension in the note below.

  • A string property called name, used to store the user’s name.

To define these properties:

  1. In the event-app/api/schemas/ directory, create a new file with the name user.json (e.g. use the command
    touch user.json to do so), and open this new file.

  2. Copy the code snippet (below) and paste it into this empty file.

    {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "type": "object",
        "properties": {
            "userid": {
                "x-datastore-source": "document.$id",
                "description": "The ID of the user."
            },
            "name": {
                "type": "string",
                "example": "Keanu Reeves",
                "description": "The name of the user."
            }
        },
        "required": ["userid", "name"]
    }
  3. Save the updated user.json file.

This new userid property uses a Datastore extension directive to the JSON Schema called x-datastore-source, as is the case with the eventid property covered in Tutorial 3. The x-datastore-source directive instructs Datastore to expose the document’s internal ID (referred to by document.$id) to a document object that can be utilised by your app through the userid property.

If a PUT (as opposed to a POST) method in the API specification is used with the userid Datastore extension property definition (in this JSON schema) to create a new user document, then this allows any ID specified by the app (during the user document’s creation) to be utilised and assigned to the user document’s internal ID, instead of Datastore’s automatically generated UUID. The details of this are covered further down.

Define properties for users collections

Like user documents above, a users collection also requires that its properties are defined in a JSON scheme file, similar to those defined for the events collection.

To define the required property for users collections:

  1. In the event-app/api/schemas/ directory, create a new file with the name users.json (e.g. touch users.json), and open this new file.

  2. Copy the code snippet (below) and paste it into this empty file.

    {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "type": "array",
        "items": {
            "$ref": "./user.json"
        }
    }
  3. Save the updated users.json file.

Define user document endpoints

After defining the user document properties, define the relevant API endpoints in your API specification to handle user documents. This entails defining:

  • A collection named users (accessible through the /users endpoint), which defines a GET request.

  • Individual user documents (contained within the users collection), each of whose name is used as the ID provided by your app and made accessible through the /users/{userid} endpoint. This endpoint defines a GET and PUT method.

If a POST method were defined on the /users endpoint to create new user documents, as is the case with the /events endpoint for creating new event documents, then Datastore automatically generates and assigns a UUID for a new user document.

Since your event app will utilise an external ID (i.e. one provided by your app) for user documents, then a PUT method must be defined on the /users/{userid} endpoint (as opposed to POST method defined on /users) in the API specification. Datastore then requires that this ID value is provided as a property value in the URL of the document’s endpoint itself.

To define these endpoints:

  1. In the event-app/api/ directory, open the api.yaml file.

  2. Copy the entire YAML snippet below, and paste it immediately between paths: (line 8) and /events: (line 9) of your api.yaml file, thereby inserting this YAML snippet (below) immediately under the paths: definition in this file.

      /users:
        get:
          description: Get a list of users
          x-datastore-acl: public
          responses:
            '200':
              description: All users are returned
              content:
                application/json:
                  schema:
                    $ref: './schemas/users.json'
      /users/{userid}:
        parameters:
          - in: path
            name: userid
            description: ID of a user
            required: true
            schema:
              type: string
              example: 'jsmith'
        get:
          description: Get a user profile by ID
          x-datastore-acl: public
          responses:
            '200':
              description: The user profile is returned
              content:
                application/json:
                  schema:
                    $ref: './schemas/user.json'
        put:
          description: Adds a new user
          x-datastore-acl: public
          responses:
            '200':
              description: The user profile is returned if the user exists
              content:
                application/json:
                  schema:
                    $ref: './schemas/user.json'
            '201':
              description: The user info is returned for new user
              content:
                application/json:
                  schema:
                    $ref: './schemas/user.json'
          requestBody:
            content:
              application/json:
                schema:
                  $ref: './schemas/user.json'
    Spacing is very important in YAML files. Therefore, ensure that indents are kept to two spaces each, with both the /users: and /users/{userid}: lines each indented to two spaces from the left, which should match the indentation of the existing /events: and /events/{eventid}: lines.
  3. Save the updated api.yaml file.

Modify the existing event document endpoints

As described in Blueprint design implementation approach above, since the /events and /events/{eventid} endpoints will be made accessible through the /users/{userid} endpoint (defined above), the API specification must be modified to handle this change.

To implement these modifications:

  1. In the event-app/api/ directory, open the api.yaml file.

  2. Change the existing /events: line to /users/{userid}/events:

  3. Change the existing /events/{eventid}: line to /users/{userid}/events/{eventid}:

  4. Save the updated api.yaml file.

Configure user identity

Datastore requires that JSON web tokens (JWTs) represent trusted user identities/data, which are utilised primarily in API requests to Datastore to access user-restricted data. Such data access is implemented through access controls defined in the API specification. JWTs can also be used by your app to retrieve information about the currently authenticated user.

The value of a Datastore JWT consists of a set of trusted user data that has been hashed by a secret key which is only known to Datastore and the user authentication service (e.g. Matrix).

The Datastore service local simulator includes functionality to generate a JWT, thereby providing a simulated user login experience which can be utilised to test access controls defined in your API specification. In production, a user would authenticate against an authentication service such as Matrix, which in turn would generate the JWT (hashed using the secret key configured in Matrix) that can be used by your app to send requests to Datastore.

To implement this simulated JWT generation functionality:

  1. In the event-app directory, edit the settings.js file.

  2. Add the line jwtURL: 'http://0.0.0.0:7001/__JWT/issueToken' to the const settings definition, so that you end up with something like:

    const settings = {
        serviceURL: 'http://0.0.0.0:7001/abcd1234',
        jwtURL: 'http://0.0.0.0:7001/__JWT/issueToken'
    };

    If this app were used in production, this jwtURL value would instead use the domain and URL path of your authentication service. For example, on a Matrix service, with the JSON web token extension installed, this URL can be obtained from your JSON web token asset’s Web Paths page.

    If you used a different port other than 7001 (above) for serviceURL, change the jwtURL port value to match that of the serviceURL.

  3. Save the settings.js file.

Clear any existing stored test data

Since the existing stored test data in your simulated Datastore service used old API paths, you should clear and remove this test data, since the revised API specification no longer defines paths to access this stored data. Therefore, your old test data is effectively inaccessible.

  • To clear all existing stored data in your event app’s Datastore service, run the following command (from the event-app directory):

    $ dxp datastore simulator clear --blueprint api/api.yaml --force

    If ✔ Done! is shown, then your data has been successfully cleared from Datastore.

Follow this process after any structural changes to your API specification during development, or whenever you need to test a 'first-run' experience with your Datastore service.

If you have multiple simulated Datastore services used across multiple projects, clearing your stored test data in one of these Datastore services does not affect the data in other Datastore services, since each of these services is managed in its own container.

Copy the 'tutorial 5' files over to your event-app directory

Now copy the next level of your app’s functionality (which handles your API changes above) from the source tutorial repository over to your event-app directory.

  1. From the event-app directory, copy the content of the step6 directory to the event-app directory:

    $ cp -r ../tutorial/step6/*.* .
  2. Refresh or re-open the initial index.html file in the event-app directory.

Test the updated app

Your event app should now present you with login page, which requires users to log in with their Username, before the rest of the app’s functionality can be accessed. Since no users are configured in the app yet, you first need to create a new user.

  1. Click the Register button begin creating a new test user.

  2. In the resulting Create new user dialog box, specify the Username and Name of this test user, and then click Create user.

  3. On the resulting page, click the Create an event button and create a new test event.

    Since events are now associated with specific users (and the fact that you cleared all existing stored data from your Datastore service), none of your old events are available, and your app behaves as though it has been run for the first time.
  4. At the top-right, click your username and from the drop down menu, choose Log out.

  5. On the login page, click the Register button again and create a new test user.

  6. Log in as this new user test.
    Notice that your new user has no events.

  7. Log out (as you did previously) and log in again as your original test user.
    The test event you created as this original test user is now visible and accessible.

Examine how the JS SDK is used to simulate logins

The event app incorporates two methods in two JS files to simulate logins.

Examine the login function in the login.js file

In the event-app directory, open the login.js file and examine the const login …​ function definition near the top of the file.

The login.js file is used to hold all simulated login-related code for the event app, and for this reason, this file’s logic has been separated from the main event app’s functionality (encapsulated in main.js). In a production environment, the login.js file would not be used. However, this file could be adapted for use in other JavaScript app projects.

The following function simulates login changes:

const login = () => {
    getUser().then((user) => {
        // Only in Datastore Sim class.
        datastore.setSetting('jwtPayload', {userid: user.userid}); (1)
        ...
    }).catch((error) => {
        ...
    });
};
1 This JS SDK setSettings method call to your Datastore service either sets (or updates an existing) jwtPayload object property of the settings object (used to initially configure interactions between your local JS SDK and Datastore service in tutorial 1).
This call sets the userid property (of the settings object’s jwtPayload object) to that of the app’s logged in user’s userid value. Your Datastore service then issues a new JWT representing this new user (user.userid).
Note: The jwtPayload object property is only available when using the JS SDK simulator class, since this method call effectively makes the value of user.userid trusted data.
This property is not available in the production version of the JS SDK class, since the creation of users and the establishment of trust between the user and Datastore service is handled by the production authentication service (whose URL is configured in the settings object); not by the Datastore service itself.
The login.js file’s const getUser function also provides functionality that allows you to log in as an existing user by simply appending ?userid=<username> to the end of the app’s URL in your browser, where <username> represents that of an existing user.

Examine the init function in the main.js file

In the event-app directory, open the main.js file and examine the const init …​ function definition near the top of the file.

This function in the main event app’s code base (i.e. main.js) identifies the currently logged in user, so that your event app can then access the events which relate to this user. This code operates the same way in the simulated Datastore service as it would in production. Your app requests the JS SDK to provide the content of the JWT and if the Datastore service is:

  • part of the simulator, the JWT is constructed by the Datastore service from a JS SDK request made in login.js

  • in production, the JWT is retrieved from the authentication service, such as Matrix.

const init = () => {
    const payload = datastore.jwt.getPayload(); (1)
    if (payload.hasOwnProperty('userid') === true) { (2)
        datastore.collection('users').doc(payload.userid).get().then((user) => { (3)
            ...
        }).catch((error) => {
            ...
        });
    }
};
1 Retrieves the JWT payload through the JS SDK and assigns it to the payload const.
Note: This method is available in both the simulator and production versions of the JS SDK class.
2 Checks if the payload of the JWT contains the userid property.
3 Accesses data associated with the current user identified by userid.

Examine the JS app’s code changes to event-handling and user-creation methods

Since changes have been made to the API specification, your app’s JS SDK method calls to your revised API spec need to be updated accordingly.

You will notice a new JS SDK method call has been introduced to allow the creation of a user document (in the users collection) with a predefined ID from your app.

Examine the revised getEvents function in main.js

In the getEvents function from tutorial 1, the original method call:

  • datastore.collection('events').get()

must now be modified to:

  • datastore.collection('users').doc(currentUser.userid).collection('events').get()

/**
 * Gets all events for My Event Manager
 */
const getEvents = () => {
    datastore.collection('users').doc(currentUser.userid).collection('events').get().then( (1)
        (events) => {
            if (events.length === 0) {
                ...
                // Code to handle what happens when there are no events.
                ...
            } else {
                events.forEach((event) => {
                    printEvent(event); (2)
                });
            }
        }
    );
};
1 The modified JS SDK call to get a list of events belonging to a specific user.
The final collection() method in this line prepares a collection request on the events collection, within a specific user document of the users collection (defined in the revised API specification above).
The doc() method is called on the top-level collection object (set by datastore.collection('users')) to set the currently logged in user’s user document (specified by its ID) as an object. This ID value is retrieved from a property of your app’s current user document object (currentUser.userid).
The collection() method is then called on this doc() object to prepare the collection request object on the events collection (within /users/{userid}), also defined in the revised API specification above, where event documents are now available on each user document.
The get() method is then called on this collection() object to send a GET request to retrieve the events collection’s array of individual event document objects.
This method uses the API’s get definition within /users/{userid}/events, defined in the revised API specification above, to retrieve the requested collection of event documents (associated with your app’s currentUser.userid value) from your Datastore service.
Then, if all goes well …​
2 Outputs a list of each event in the events collection.

Examine the createUser function in login.js

In the addEvent function of main.js from tutorial 1, the original method call:

  • datastore.collection('events').add(data)

allowed the creation of a event document with an ID automatically generated by the Datastore service.

However, since an external ID is being used by your app to create a new user document (described above), then the ID value for the new user document must be provided as the second argument in the add() method call:

  • datastore.collection('users').add(data, userid)

/**
 * Creates a new user
 */
const createUser = () => {
    const userid = document.querySelector('#createUser .form-control.user-username').value; (1)
    const name = document.querySelector('#createUser .form-control.user-real-name').value; (2)

    if (userid) {
        const data = {userid};
        if (name && name !== '') { (3)
            data.name = name;
        }

        datastore.setSetting('jwtPayload', {userid}); (4)
        datastore.collection('users').add(data, userid).then((user) => { (5)
            window.location.href = window.location.pathname + '?userid=' + userid; (6)
        }).catch((error) => {
            printError('#createUser .modal-body', error); (7)
        });
    } else {
        printError('#createUser .modal-body', {title: 'Username is required'}); (8)
    }
};
1 Retrieves the user ID property value (from the app’s Username field) for the newly created user document, assigned to the userid const.
2 Retrieves the user name property value (from the app’s Name field) for the newly created user document, assigned to the name const.
3 Checks to ensure that the value of name is defined and is not an empty string, and if so, assigns its value to the name property of the data object.
Note: The data object’s values will be used to create the new user document’s values in your Datastore service.
4 The JS SDK simulator method call (described in more detail above) to 'log in' the new user whose user document will be created in the Datastore service.
5 The JS SDK call to add the new user document (whose ID is the app’s userid value) to the users collection.
The datastore.collection() method prepares a collection request on the top-level collection (i.e. users defined in the revised API specification above).
The add() method is then called on this collection() to send a PUT request (on the prepared collection request) to add the new user’s property values (in data), as a new document (whose ID is the app’s userid value) of this users collection.
This method uses the API’s put definition within /users/{userid}, also defined in the revised API specification above.
Note:
  • Using a second argument in the add() method instructs the JS SDK to utilise a put definition within the relevant API specification’s endpoint. Otherwise, if add() only contains a single argument, the JS SDK utilises the post definition within the relevant API specification’s endpoint.

  • In the next tutorial, this JS SDK call will require a JWT representing the same user as the app’s userid value, which is why the JS SDK simulator method call to 'log in' this user (to retrieve their JWT) is made in the previous line of code.

6 Redirects your browser to the current URL with the parameter ?userid= and the new user’s ID value (userid) appended to the end of the URL.
7 Outputs the error data through the app when adding a new user fails (e.g. when Name is missing or the Datastore service could be unavailable).
8 Outputs the error when the userid value (i.e. Username) is missing.

Now that you understand how to implement multi-user support into API endpoints, by associating event documents with individual user documents, you can now extend the functionality of your event app and API specification to restrict user access to event documents with access control rules.

© 2015- Squiz Pty Ltd