-
Notifications
You must be signed in to change notification settings - Fork 1
10_Creating Modules
When you are planning a new module, the first question you have to ask yourself is the following: Does this module serve a fundamental core functionaliy that is used/consumed by many other modules (email, roles, authentication, ...) or is this a module specific for a business-logic usecase (garage, dossier, bill, ...)? If it is generic, place the module in the folder /backend/src/flox/modules/[your-module-name]/**
and /frontend/src/flox/modules/[your-module-name]/**
, otherwise place them in /backend/src/modules/[your-module-name]/**
and /frontend/src/modules/[your-module-name]/**
.
Internally, the entire flow from user through frontend to backend and database is structured as shown in the following diagram. Frontend and Backend modules are illustrated in yellow and purple, respectively.
For further explanations regarding the frontend's and backend's internal structure (such as the distinctions between the various intermediary layers), consider reading the related framework's documentation (Quasar/Vue 3 and NestJS, respectively).
Generally, a backend module's file structure is as follows. You may omit any unused folders or files.
flox/modules/[your-module-name]
│
├── dto
│ ├── args
│ ├── input
│ └── output (optional)
├── entities
├── helpers (optional)
├── [your-module-name].module.ts
├── [your-module-name].resolver.ts
├── [your-module-name].service.ts
├── config.ts // Contains default module configuration
└── ...
Important: config.ts
is, as opposed to frontend modules, not named index.ts
, since NestJS has problems with barrel files. Thus, we simply don't use files named index.js
in the backend.
Now, to create a new module, create the folder structure outlined above in the appropriate location in the backend. Starting with these empty files, your first line of thought should be: Should I inherit my module from any abstract modules that already exist? There are a few Abstract Modules already implemented as part of Flox. By inheriting from any one of these modules, you can leverage CRUD, Search or AccessControl functionality, potentially even more in the future. Hence, check this directory regularly, get familiar with the available abstract modules, read their documentation, and make an informed decision from which to inherit (or none at all).
Next, you should start by defining your **entities **in the entities
folder. Create a new file for every entity that your module needs and be sure to use the class validator to validate the objects properties. Most entities either inherit the BaseEntity /core/base-entity/entities/base-entity.entity
or a predefined entity given by the abstract parent class. A simple entity may look like this (import omitted):
@ObjectType()
@Entity()
export default class User extends BaseEntity {
@Field(() => String, { description: 'Username' })
@Column()
@IsString()
@MinLength(6)
@MaxLength(50)
username: string;
}
Next, you should think about the functionalities that are needed to manipulate your entities: Create new ones, change existing ones etc. Implement these functions as part of your modules Service. Your service functions may use arguments that you need to define as dto/args
or dto/inputs
. Read more about the difference here.
Note that your service functions should not include logic to validate whether the user accessing these functions does indeed have the right to access them. Think of your service functions as functions that are primarily used by us developers internally: Other modules may import your service and use your service functions to query for your entities or update them. Your service must ensure that these updates are performed correctly and that the state change happens in the intended way, but to ensure that the function actually should be called is not your job - that's the job of the developer that is calling your service. Your service functions are like a contract: They do manipulate your entities and they do it well, but they do not try to prevent someone from calling the service as they need it.
The fault prevention is job of the resolver that you write next. It exposes service functions to the public using GraphQL Endpoints. Since everyone can call these endpoints freely, it is the resolvers job to make sure it validates the request and only calls the appropriate service functions when all inputs are correct and the calling user has indeed the appropriate rights to manipulate the data. Hence, the Resolver uses function annotations that prevent endpoints to be called if the caller does not have sufficient rights or is not of an appropriate role.
Lastly, when you have developed the resolver, you assemble all of your files together and build the module. The module class is always minimal and trivial: You import your entities, services and resolvers and declare them as imports
, providers
or exports
, respectively. With this step, you are done with the backend work.
If you are building a flox module, you have to make some additional steps: Since flox modules can be parametrized using config files to make them more flexible for each individual clients usecase, you need to register the module and define the possible config parameters.
Each module has its own config.ts
file in which all possible config parameters are specified as a type, together with the corresponding default values. Make sure that your module has appropriate default values such that it can work out-of-the-box. For conveniance, the configs for all modules can be set centrally in a flox.config.json
file. This makes setting up new projects easy, as you can tweak all config parameters in one place without having to touch the default configs. The config.ts
exports a function moduleConfig()
that merges the flox config with the default configs. Import and use this function to access the config variables wherever you need them. Note that the flox config file can only define the config parameters that are defined in the typedef, all other parameters are simply ignored.
type MyModuleConfig = {
name: string,
};
const defaultConfig: MyModuleConfig = {
name: 'Default Name',
};
export function moduleConfig(): AuthModuleConfig {
return mergeConfigurations(
defaultConfig,
floxModuleOptions(MODULES.MY_MODULE),
) as MyModuleConfig;
}
export default moduleConfig;
{
"modules": {
"mymodule": true
},
"moduleOptions": {
"mymodule": {
"name": "Some other name"
}
}
}
To correctly setup your module, complete the following steps:
- Add the module to MODULES.ts Enum
- In the flox.config.js, add your module to the
modules
object and set its value totrue
to activate it. Further, in themoduleOptions
object, add your module name as a key and use an empty object as a value. If you want to accept config parameters for your module, add them to the object and set default values. - In file flox/flox.ts, add a case to the switch in
floxModules()
that pushes any actual Nest modules your module uses. - In flox/flox.ts, add a case to the switch in
floxProviders()
that pushes any providers your module uses.
Note that for some modules (e.g. role management), you may not even need an actual Nest module, but only some other files (e.g. guards).
Generally, a frontend module's file structure is should be quite similar to the frontend's own structure. You may omit any unused folders or files.
Most importantly, each module also has an index.ts
file that contains all configuration options (as well as their default values) for the module.
flox/modules/[your-module-name]
│
├── components
│ ├── dialogs
│ └── ...
├── entities
├── enums (optional)
├── services
├── stores (optional)
├── tools (optional)
├── [your-module-name].mutation.ts
├── [your-module-name].query.ts
├── index.ts // Contains default module configuration
└── ...
The purpose of the components
folder is quite trivial: It holds components that are specifically tied to this module. This could be a component that tracks the number of unread notifications in the backend and displays a bell accordingly. By clicking on the bell, it expands and shows the unread notifications. This is a great example of a Vue component that is specifically tied to a module, in this example the NotificationModule.
The entities folder contains TypeScript class definitions containing copies of the backend entities - copies in the sense that these frontend entities model the same structure and objects, but they are often a superset of the data available in the backend as the resolvers may not expose certain rows from the database entry. Almost all of these object inherit from the frontend BaseEntity. A simple entity for the user may look like this (matching the example object from the backend part of this guide):
export default class UserEntity extends BaseEntity {
@IsOptional()
@IsString()
@MinLength(6)
@MaxLength(50)
username?: string;
}
Note that the username is optional here while it was not optional in the backend. In fact, all properties on frontend entities must be marked as optional. Why? Well, because they essentially are all optional. When we define a GraphQL Query, we decide for each query which object properties we want to be included in the response. Naturally, depending on our choices, the response object may or may not have certain keys. Hence, they are all optional. The only key that is always present is the objects uuid
, which is defined as a required property in the BaseEntity. This has some unfortunate consequences: As all of our properties (besides the uuid) are optional, we may never rely on their existance in the code. As typescript is type safe and we configured it to be also null-safe using linting rules, you will always need to react to the case that a property does not exist. This is additional effort but also reflects the real-world and is hence needed: Changing the GraphQL query may lead to the absence of certain keys, and writing our code to adapt to this situation makes it stable and safe.
The enums
folder is optional and also trivial: It holds ENUMs needed by either the entities or any other typescript files. The tools
folder is also trivial: it contains files with helper functions, such as the auth.tools.ts
helper that is part of the auth
Module: This tool includes a function that extracts the bearer token from the currently logged in user. As this functionality might come handy at different parts of the application, it is extracted as a tool. Tools may be used internally by the module itself but may also be imported by other modules as needed.
The next important and necessary file is the [your-module-name].query.ts
: This file contains Apollo gql
query definitions for all the @Query
endpoints that you define in your backend resolver. A typical query may look like this:
export const GET_MY_ENTITY: QueryObject = {
query: gql`
query MyEntity($uuid: ID!) {
MyEntity(uuid: $uuid) {
uuid
name
description
__typename
}
}
`,
tables: [TABLES.MY_ENTITY],
cacheLocation: 'MyEntity',
};
export const MY_ENTITY_QUERIES: QueryObject[] = [GET_MY_ENTITY];
Several things must be noted here.
First, the query object follows has three keys: query
, tables
and cacheLocation
. The query
-key contains the graphql query itself, more on that note later. The tables
-key lists all tables that are affected by this query, tables meaning database tables in this context, and affected meaning that data from these tables is used in the query, directly or indirectly. All tables are defined in the following file: /src/flox/TABLES.ts
. This is important when it comes to caching: Apollo, the graphql service we use, automatically caches all queries we perform in the frontend. Caches are devalidated when the database tables themselves change and queries become inalid. In the current flox implementation, Apollo has no websocket connection to the backend and hence does not know when the database tables actually change. What we do know is when the user itself performs any mutations on these tables. Hence, to hint the caching systems for which table changes to watch out for, the tables array is needed. Finally, the cacheLocation
key indicates the subkey from which the actual data can be extracted. In our example, the MyEntity
is the query name under which the actual data is returned. By explicitly defining this key, our services can automatically extract the relevant data from a query.
Second, we explicitly declare the possible input arguments for this query. When you have your backend running, this input arguments and - in fact - the whole query object you write will automatically be linted based on the schema.gql file that is generated by the backend. This schema file is super important: It contains auto-generated definitions for all graphql endpoints present in the whole backend. Hence, your query defined in the frontend must be compatible with this schema file, otherwise your query won't run. Note that this schema file sometimes fails to auto-generate. In this case, trigger some change in any of the resolvers while your backend is running to force Nest to re-generate the schema, and re-open your [your-module-name].query.ts
file in the frontend such that the linter re-evaluates the linting.
Third, the queries file exports a constant array containing all defined queries. This is again needed for the caching system: When a graphql manipulation is performed, the affected queries must be identified and their cache invalidated. Hence, the file src/flox/all.queries.ts
imports and joins all graphql queries, the ones from flox and custom ones.
import { QueryObject } from 'src/apollo/query';
import { MY_ENTITY_QUERIES} from 'src/flox/modules/my/entities/my.entity.ts';
...
// Queries for all modules
export default [
...MY_ENTITY_QUERIES
] as QueryObject[];
The [your-module-name].mutation.ts
is structured similarly to the query file. The difference is obvious: It does not contain grapqhl queries that fetch data but mutations that create or change data.
export const CREATE_MY_ENTITY: MutationObject = {
mutation: gql`
mutation CreateMyEntity(
$name: String!
) {
CreateMyEntity(
createMyEntityInput: {
name: $username
}
) {
uuid
name
createDate
__typename
}
}
`,
tables: [TABLES.MY_ENTITY],
type: MutationTypes.CREATE,
cacheLocation: 'CreateMyEntity',
};
The mutaiton
-key is similar to the previously encountered query
-key. It contains a gql-string that defines the mutation input objects and the return types. The tables
and cacheLocation
serve exactly the same purpose here as they do in the query-case. The one new key is type
, which specifies the MutationType as either CREATE, UPDATE, DELETE or INVALIDATING_UPDATE. The type serves as a hint for the caching system: When an entity is updated, the query to the entity itself must be invalidated, but the query to another entity may not be.
You don't need to export an array of all your mutations nor is there an aggregator file that aggregates them. As the mutations are never cached, we don't need to informt the caching system about the existance of these mutations.
Now that we have our entities, queries and mutations defined, we are ready to use these to communicate with the backend. The backend communication is exclusively done through [your-module-name].s.tervice.ts
files, located in the services subfolder of a module. This file imports the queries and mutations, wraps them as functions and exports them for the components to consume. The components themselves rarely ever execute queries or mutations themselves (with some notable exceptions like the data-table).
Service functions are most ofteh trivial. They simply execute the query and return the data in the right entity type.
/**
* Get a certain MyEntity by its uuid
*
* @param uuid - uuid of entity to fetch
* @returns MyEntity
*/
export async function getMyEntity(uuid: string): Promise<MyEntity> {
const { data } = await executeQuery<MyEntity>(GET_MY_ENTITY, { uuid });
return data;
}
Note that we pass the MyEntity
type to the executeQuery function and also hint the return type of the function itself. This converts the untyped graphql query to a typed entity. The executeQuery
is a simple query function that returns a plain object without any reactivity, but does consider the cache. Hence, the method returns instant results when some cache is available.
There are other methods available, like the subscribeToQuery
:
/**
* Subscribes to all MyEntities
*
* @returns reactive array of MyEntities
*/
export function subscribeToAllMyEntities(): Ref<
MyEntity[] | null
> {
const { data } = subscribeToQuery<NotificationEntity[]>(
GET_ALL_MY_ENTITIES
);
return data;
}
The graphql query GET_ALL_MY_ENTITIES
is nothing special: It is just a query that fetches all available entities. The only special thing in this context is the usage of the subscribeToQuery
instead of the executeQuery
. Subscribing to a query returns a vue ref, not a static object. The ref will automatically update when the underlaying data changes. Consider the following example: You subscribe to a query that returns all MyEntities. You get back an array containing 3 entities as a Vue ref, which you use to iterate over the items and display them in the UI. Now, in a popup, the user creates yet another MyEntity. Since this corresponds to a mutation on the same table, the cache invalidates the result for the previous request to fetch all entities. As the request is invalidated, it is automatically repeated and your Vue ref is updated accordingly. It changes from holding 4 instead of 3 entities, your UI updates automatically without you having to do any manual work. Would you have used a executeQuery
instead, you would have to manually re-execute the query to get the newest results.
The question arrises: Why should I not always use subscribeToQuery
instead of executeQuery
as it is strictly more powerful? Well, it is also harder to work with. Note that subscribeToQueryworks synchronously: You get back a ref immediately, but the ref starts with the state
nullbefore the backend answered the first call. Also, as your data may change unexpectedly, you must react accordingly and keep your user interface in sync. Lastly, it is not as powerful as it seems: The subscription only updates when the user executes a mutation that invalidates the tables locally - it does not re-execute the queries when the backend data changes through someone elses mutation. Hence, the possible applications are kind of limited. In 90% of the cases, a regular
executeQuery` does the job and does it well. Change to a subscription if you see yourself in a situation in which you want to start polling or re-fetching data when new entities are created or existing ones are deleted.
Lastly, the service will also include functions that warp manipulations:
/**
* Creates a MyEntity
*
* @param name - entities name
* @returns the newly created entity
*/
export async function createMyEntity(
name: string,
): Promise<UserEntity | null> {
const { data } = await executeMutation<MyEntity>(CREATE_MY_ENTITY, {
name
});
return data ?? null;
}
Note that mutations must not succeed and can hence return null. With these three types of functions, all possible service functions can be written.
Modules may have their own pinia stores to manage their internal state. Define these stores in their own stores
subdirectory, they will automatically be registered globally. You can use the store inside your module components but also outside, in other modules.
Some modules may regularly need handy helper functions. Define them in tools
such that they can be easily found by other modules as well.
The same rules that apply to backend modules also apply to frontend modules: As they are general purpose, they are parametrizable via config files and must hence be registered as flox modules. The relation between the flox.config.ts
and the invidual module configs (stored in the modules index.ts
instead of config.ts
is exactly the same as in the backend). Follow these steps to register the modules correctly:
- Add the module to MODULES.ts Enum
- In the flox.config.js, add your module to the
modules
object and set its value totrue
to activate it. Further, in themoduleOptions
object, add your module name as a key and use an empty object as a value. If you want to accept config parameters for your module, add them to the object and set default values. - Within
index.ts
(example), specify the default module configuration (may be empty for modules that are not configurable). Note that frontend modules don't have to be registered to be used. However, module components' (except for dialogs) templates should be wrapped with the FloxWrapper component with the corresponding:module
parameter set. This will prevent the component from being used if the corresponding module is not activated inflox.config.json
, and instead show a corresponding error message.
This is done to alert developers if they are using module components without having enabled the accompanying module, as some module functionalities such as missing services could possibly break the application.