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:
-
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.
-
-
Restructure the API specification to include a root level
users
collection containing{userid}
documents, each of which contains anevents
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 utilize 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 |
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:
-
In the
event-app/api/schemas/
directory, create a new file with the nameuser.json
(e.g. use the command
touch user.json
to do so), and open this new file. -
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"] }
-
Save the updated
user.json
file.
This new If a PUT (as opposed to a POST) method in the API specification is used with the |
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:
-
In the
event-app/api/schemas/
directory, create a new file with the nameusers.json
(e.g.touch users.json
), and open this new file. -
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" } }
-
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 Since your event app will utilize an external ID (i.e. one provided by your app) for user documents, then a PUT method must be defined on the |
To define these endpoints:
-
In the
event-app/api/
directory, open theapi.yaml
file. -
Copy the entire YAML snippet below, and paste it immediately between
paths:
(line 8) and/events:
(line 9) of yourapi.yaml
file, thereby inserting this YAML snippet (below) immediately under thepaths:
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. -
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:
-
In the
event-app/api/
directory, open theapi.yaml
file. -
Change the existing
/events:
line to/users/{userid}/events:
-
Change the existing
/events/{eventid}:
line to/users/{userid}/events/{eventid}:
-
Save the updated
api.yaml
file.
Configure user identity
Datastore requires that JSON web tokens (JWTs) represent trusted user identities/data, which are utilized 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 utilized 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:
-
In the
event-app
directory, edit thesettings.js
file. -
Add the line
jwtURL: 'http://0.0.0.0:7001/__JWT/issueToken'
to theconst 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) forserviceURL
, change thejwtURL
port value to match that of theserviceURL
. -
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-next 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.
-
From the
event-app
directory, copy the content of thestep6
directory to theevent-app
directory:$ cp -r ../tutorial/step6/*.* .
-
Refresh or re-open the initial
index.html
file in theevent-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.
-
Click the Register button begin creating a new test user.
-
In the resulting Create new user dialog box, specify the Username and Name of this test user, and then click Create user.
-
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. -
At the top-right, click your username and from the drop down menu, choose Log out.
-
On the login page, click the Register button again and create a new test user.
-
Log in as this new user test.
Notice that your new user has no events. -
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:
|
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.