Managing Data Structures
This is part three of our series on scripting with AcceleratXR. In part two we discussed how to create new custom REST API endpoints. In part three we will build upon that to create a REST API that serves as a RESTful interface to a custom data structure. Go ahead and open up your test workspace in Visual Studio Code and let’s get started.
In AcceleratXR, data storage is managed using a strictly typed system of classes that are stored in a database using an Object-Relational Mapping layer (TypeORM). This makes it possible to easily switch between database types based on the different needs of the data structure and service. In fact, it’s possible to have two classes be stored in two entirely different databases within the same service. The ORM framework supports popular SQL databases as well as NoSQL such as MongoDB. Most services in the AcceleratXR platform have been written to use MongoDB. We will continue that tradition for today’s tutorial.
Let’s imagine that we’re building an RPG in which players can create unique characters (let’s also imagine that AcceleratXR doesn’t already have a system for this). Each character needs certain attributes stored that matter to our gameplay. Things like health, mana, current equipment and an inventory of items. We’ll start by creating the Character class.
First create a new file called Character.ts. Like routes, it’s recommended practice to place your data structure classes in the models sub-folder.
Now paste the following contents into your file.
1import { Column, Entity, Index } from “typeorm”;
2import { BaseMongoEntity, Cache, Identifier, Model } from “@acceleratxr/service-core”;
3import InventoryItem from “./InventoryItem”;
4
5/**
6 * An `Character` is a unique character of a user within the system. Users can have multiple characters per account and the character can have associated data such as inventory, progress, achievements, etc.
7 */
8@Cache()
9@Entity()
10@Model(“mongodb”)
11export default class Character extends BaseMongoEntity {
12 /**
13 * The universally unique identifier of the user that the character belongs to.
14 */
15 @Column()
16 @Index()
17 public userUid: string = “”;
18
19 /**
20 * The unique name of the character.
21 */
22 @Identifier
23 @Index()
24 @Column()
25 public name: string = “”;
26
27 /**
28 * A biographical description of the character.
29 */
30 @Column()
31 public biography: string = “”;
32
33 /**
34 * The amount of health that the character currently has.
35 */
36 @Column()
37 public health: number = 100;
38
39 /**
40 * The amount of mana that the character currently has.
41 */
42 @Column()
43 public mana: number = 100;
44
45 /**
46 * A map of the current items the character has equipped. The key is the slot name, the value is the uid to the item
47 * that is equipped.
48 */
49 @Column()
50 public equipment: Map<string, string> = new Map();
51
52 /**
53 * A list all items currently in the character’s possession.
54 */
55 @Column()
56 public inventory: Array<InventoryItem> = [];
57
58 /**
59 * An arbitrary map of key-value pairs containing the characteristics of the character.
60 */
61 @Column()
62 public attributes: any = undefined;
63
64 constructor(other?: any) {
65 super(other);
66
67 if (other) {
68 this.userUid = other.userUid ? other.userUid : this.userUid;
69 this.name = other.name ? other.name : this.name;
70 this.biography = other.biography ? other.biography : this.biography;
71 this.health = other.health ? other.health : this.health;
72 this.mana = other.mana ? other.mana : this.mana;
73 this.equipment = other.equipment ? other.equipment : this.biography;
74 this.inventory = other.inventory ? other.inventory : this.inventory;
75 this.attributes = other.attributes ? other.attributes : this.attributes;
76 }
77 }
78}
The first thing you may notice about this class is that inherits from BaseMongoEntity. The BaseMongoEntity class provides basic information for data that is to be stored in a MongoDB instance. This includes properties such as uid, dataCreated, dateModifed and version. The uid property uniquely identifies the object across all others in the database. The date fields should be self explanatory and the version field is used for optimistic locking. Note that if you were writing for a SQL database you would use the BaseEntityclass instead.
You may also notice the three decorators at the top of the class; @Cache, @Entity and @Model. These are all very important as they tell the server what you intend to do with this class. The @Entity decorator is used to indicate that instances of this class will be stored in a database managed by TypeORM. The @Model decorator is used to identify which database connection that instances of the class should be bound to. In our example, instances of the Character class are bound to the mongodb database connection. Finally, the @Cache decorator tells the server that queries to this class should be cached. Caching speeds up searches for data by storing the results of frequently requested queries in memory. This is also commonly referred to as a Second-Level Cache.
The contents of the class is pretty straight forward. You’ll notice that it is essentially a struct with a simple copy constructor. Each property that will be stored in the database is decorated with @Column. This again tells TypeORM what and how to store instances of the object in the database. The name property also has the @Identifier and @Index decorators. These tell the server that the name property should be indexed in the database (to further speed up queries) and that the property is also a unique identifier (more on this later).
The copy constructor is very simple, it takes an object of type any and then selectively copies all existing values corresponding to properties defined in the class. This serves two purposes. First, it makes converting any arbitrary object to our data type easy. Second, it provides an automatic filtering mechanism, getting rid of any properties we don’t actually care about.
Now that we’ve created our data structure let’s look at how to write a route handler that will allow us to manage it. Create a new file routes/CharacterRoute.ts and paste the following contents.
1import {
2 Auth,
3 Config,
4 Init,
5 Logger,
6 Model,
7 ModelRoute,
8 Repository,
9 Delete,
10 Get,
11 Post,
12 Put,
13 Param,
14 Query,
15 Route,
16 User as AuthUser,
17 AccessControlList,
18 ACLRecord,
19 Request
20} from “@acceleratxr/service-core”;
21import { JWTUser, UserUtils } from “@acceleratxr/core”;
22import { Request as XRequest } from “express”;
23import Character from “../models/Character”;
24import Count from “../models/Count”;
25import { MongoRepository } from “typeorm”;
26
27/**
28 * Handles all REST API requests for the endpoint `/characters`.
29 */
30@Model(Character)
31@Route(“/characters”)
32export default class CharacterRoute extends ModelRoute<Character> {
33 @Config
34 protected config: any;
35
36 @Logger
37 protected logger: any;
38
39 @Repository(Character)
40 protected repo?: MongoRepository<Character>;
41
42 /**
43 * Initializes a new instance with the specified defaults.
44 */
45 constructor() {
46 super();
47 }
48
49 /**
50 * Called by the system on startup to create the default access control list for objects of this type.
51 */
52 protected getDefaultACL(): AccessControlList | undefined {
53 // TODO Customize default ACL for this type
54
55 const records: ACLRecord[] = [];
56
57 // Anonymous has no access
58 records.push({
59 userOrRoleId: “anonymous”,
60 create: false,
61 read: false,
62 update: false,
63 delete: false,
64 special: false,
65 full: false,
66 });
67
68 // Everyone has create/read-only access
69 records.push({
70 userOrRoleId: “.*”,
71 create: true,
72 read: true,
73 update: false,
74 delete: false,
75 special: false,
76 full: false,
77 });
78
79 return {
80 uid: “Character”,
81 dateCreated: new Date(),
82 dateModified: new Date(),
83 version: 0,
84 records,
85 };
86 }
87
88 /**
89 * Returns all characters from the system that the user has access to
90 */
91 @Auth([“jwt”])
92 @Get()
93 private async findAll(
94 @Param() params: any,
95 @Query() query: any,
96 @AuthUser user?: JWTUser
97 ): Promise<Array<Character>> {
98 return super.doFindAll(params, query, user);
99 }
100
101 /**
102 * Create a new character.
103 */
104 @Auth([“jwt”])
105 @Post()
106 private async create(obj: Character, @Request req: XRequest, @AuthUser user?: JWTUser): Promise<Character> {
107 const character: Character = new Character(obj);
108
109 // If the userUid was not provided fill it in based on the user
110 if (user && (!character.userUid || character.userUid.trim().length === 0)) {
111 character.userUid = user.uid;
112 }
113
114 // A non-admin user cannot create a profile on behalf of someone else
115 if (user && character.userUid !== user.uid && !UserUtils.hasRoles(user, this.config.get(“trusted_roles”))) {
116 const error: any = new Error(“User does not have permission to perform this action.”);
117 error.status = 403;
118 throw error;
119 }
120
121 return super.doCreate(character, user, undefined, true, req);
122 }
123
124 /**
125 * Returns the count of characters
126 */
127 @Auth([“jwt”])
128 @Get(“/count”)
129 private async count(@Param() params: any, @Query() query: any, @AuthUser user?: JWTUser): Promise<Count> {
130 return super.doCount(params, query, user);
131 }
132
133 /**
134 * Returns a single character from the system that the user has access to
135 */
136 @Auth([“jwt”])
137 @Get(“/:id”)
138 private async findById(@Param(“id”) id: string, @AuthUser user?: JWTUser): Promise<Character> {
139 return super.doFindById(id, user);
140 }
141
142 /**
143 * Updates a single character
144 */
145 @Auth([“jwt”])
146 @Put(“/:id”)
147 private async update(@Param(“id”) id: string, obj: Character, @Request req: XRequest, @AuthUser user?: JWTUser): Promise<Character> {
148 const newObj: Character = new Character(obj);
149 return super.doUpdate(id, newObj, user, true, req);
150 }
151
152 /**
153 * Deletes the character
154 */
155 @Auth([“jwt”])
156 @Delete(“/:id”)
157 private async delete(@Param(“id”) id: string, @Request req: XRequest, @AuthUser user?: JWTUser): Promise<void> {
158 return super.doDelete(id, user, true, req);
159 }
160}
You may immediately notice a couple things that are different from the route handlers we wrote in part two. First, there is a @Model(Character) decorator in addition to the @Route decorator. This decorator is used to tell the server that this route handler is responsible for managing instances of the Character class. The second thing you may notice is that the class inherits from ModelRoute. The ModelRouteclass is a special base class containing built-in behaviors and utilities for working with data structures. Everything from the basic business logic to handle CRUD operations to automatic permission checking and second level caching is included in this base class. The last major thing you should notice about this route handler is the repo property. The repo property is a reference to the storage interface of the database. The @Repository(Character)decorator tells the server to automatically inject this reference at server start up.
There’s also a new function in this route handler class called getDefaultACL. This function is used to define the base permissions that govern user access to all class level operations (create, count, findAll and truncate). The defaultACL is also used to establish per-document permissions when objects are created using the createoperation. In this example, we are setting the default permissions to allow any logged in user with the ability to create or read records and deny unauthenticated users from performing any operations.
The remainder of the route handler functions follow the common CRUD pattern. The first function, findAll is a search function that accepts query parameters and performs a search against the database for all objects matching the specified criteria. This is easily accomplished by simply calling the doFindAll function in the base class. There are a variety of built-in functions available in the base class described here. The count built-in, for instance, returns the number of results matching the given search criteria.
The create function is a bit different. Here we first create a new Character object using the data that was passed in by the client. This provides that automatic filtering mentioned before. Then we automatically fill in the userUid property with the authenticated user’s if not already provided (for convenience).
When we defined our default permissions above you’ll notice that we allow any authenticated user to create an object. This means that anyone can create a valid Character record with any userUid association. However, since we’re dealing with player characters we want to prevent a player from creating a Character for another player’s account. So we test to make sure that the userUid in the provided character object matches that of the authenticated user. The only exception to this rule is platform super users (such as admins or moderators). We test this by checking to see if the authenticated user has a trusted role.
Finally, we call super.doCreate, passing in the new character object to create as well as the authenticated user. By default the base class will automatically create permissions for the object, inheriting from the default permissions defined above. The authenticated user will also be set with full access for the object as its owner. It is possible to override this behavior by setting the third argument to a custom acl object.
The remaining functions aren’t too complicated as they simply call their corresponding built-in function. The built-in function will automatically take care of all the dirty work when it comes to retrieving objects from the database, validating permissions, caching data and so on.
You may notice that the doUpdate and doFindById handler functions take an id path parameter as the first argument. If you recall from our data structure above we marked certain fields with the @Identifier decorator. When searching for an existing object the system uses these properties to build a special query. This makes it possible to retrieve a character object by either it’s uid or it’s name property all from the same REST API and without requiring multiple lookups.
Go ahead and try for yourself. See what new kinds of REST APIs you can create for your custom data structures. One last thing to note, the order of the function definition does matter. For example, we have count with the path /characters/count in addition to the delete, findById and update functions that each have the path /characters/:id. If the count function were defined at the bottom of the class it wouldn’t work. This is because the :id parameter in the path is a wildcard and so it’ll assume that a request to /characters/count is really a search for a character named count. This is due to the way the underlying web server processes incoming web requests (ExpressJS). So make sure that you define your functions carefully.
In part four we’ll discuss how to create a background job to create automatic time based services.