Versions Compared

Key

  • This line was added.
  • This line was removed.
  • Formatting was changed.

...

Migrations are stored in the packages/api/db/migration folder. A new migration is created with the command knex migrate:make descriptive_name --knexfile=./.knex/knexfile.js, which creates a new template file named YYYYMMDDHHMM_descriptive_name.js , where the prefix indicates the date and time.

...

In a LiteFarm deployment, the database runs as litefarm-db, a Docker container built from the official Postgres image.

The API code

An Application Programming Interface (API) is an interface that a software product provides for use by other software, rather than by human users.

The code for the LiteFarm API is in the packages/api/src folder. In a LiteFarm deployment, this Node.js server runs in a Docker container named litefarm-api. The container is built to put the API code in a standard Node image, and also uses a standard Nginx image to create a reverse proxy server.

The API borrows the Model and Controller concepts from the Model-View-Controller (MVC) architecture pattern.

Models

In the MVC pattern, a model is a dynamic data structure that directly manages the data, logic and rules of the application. Often, a model corresponds to a table in relational databases.

LiteFarm uses Objection.js to define the models. Objection extends the Knex query builder.

Code for the model that corresponds to the notification_user database table is found in packages/api/src/models/NotificationUserModel.js. A portion of the code is shown below.

...

languagejs

...

Auto-Increment ID Conventions as of August 2024

In our migration strategy:

  • Manually Set IDs: Used for the permissions table to ensure consistent IDs across different environments (local, beta, production).

    • Previously, hard-coding IDs led to "UniqueViolationError" issues when inserting new rows.

      • #2857 (comment)

      • Jira Legacy
        serverSystem Jira
        serverId815f41e5-e5fb-3402-8587-82eccc3ffab0
        keyLF-3651

  • Auto-Increment IDs: Applied by default unless there's a specific reason to manually set IDs. This helps avoid conflicts and simplifies record management.

The API code

An Application Programming Interface (API) is an interface that a software product provides for use by other software, rather than by human users.

The code for the LiteFarm API is in the packages/api/src folder. In a LiteFarm deployment, this Node.js server runs in a Docker container named litefarm-api. The container is built to put the API code in a standard Node image, and also uses a standard Nginx image to create a reverse proxy server.

The API borrows the Model and Controller concepts from the Model-View-Controller (MVC) architecture pattern.

Models

In the MVC pattern, a model is a dynamic data structure that directly manages the data, logic and rules of the application. Often, a model corresponds to a table in relational databases.

LiteFarm uses Objection.js to define the models. Objection extends the Knex query builder.

Code for the model that corresponds to the notification_user database table is found in packages/api/src/models/NotificationUserModel.js. A portion of the code is shown below.

Code Block
languagejs
/**
 * Models data persistence for users' notifications.
 */
class NotificationUser extends baseModel {
  /**
   * Tracks open subscription channels for server-sent events. To support multiple sessions by the same user,
   *   keys are user IDs; values are Maps with timestamp keys and HTTP response object values.
   * @member {Map}
   * @static
   */
  static subscriptions = new Map();

  /**
   * Identifies the database table for this Model.
   * @static
   * @returns {string} Names of the database table.
   */
  static get tableName() {
    return 'notification_user';
  }

  /**
   * Identifies the primary key fields for this Model.
   * @static
   * @returns {string[]} Names of the primary key fields.
   */
  static get idColumn() {
    return ['notification_id', 'user_id'];
  }

  /**
   * Supports validating instances of this Model class.
   * @static
   * @returns {Object} A description of valid instances.
   */
  static get jsonSchema() {
    return {
      type: 'object',
      required: ['user_id'],
      properties: {
        notification_id: { type: 'string' },
        user_id: { type: 'string' },
        alert: { type: 'boolean' },
        status: {
          type: 'string',
          /**
           * @name userNotificationStatusType
           * @desc Enumerated type for user notification status.
           * @enum
           * */
          enum: ['Unread', 'Read', 'Archived'],
        },
        ...this.baseProperties,
      },
      additionalProperties: false,
    };
  }

  /**
   * Defines this Model's associations with other Models.
   * @static
   * @returns {Object} A description of Model associations.
   */
  static get relationMappings() {
    return {
      notification: {
        relation: Model.BelongsToOneRelation,
        modelClass: Notification,
        join: {
          from: 'notification_user.notification_id',
          to: 'notification.notification_id',
        },
      },
    };
  }

  // other custom methods not shown here.
}

...

  • tableName() tells Objection the database table name for this model

  • idColumn() indicates the table columns needed to uniquely identify a table row

  • jsonSchema() supports validating the data contents of class instances before database modifications are made. Objection uses the embedded information to determine the appropriate data types, which values are required to be present, etc.

  • relationMappings() describes how this model is related to others. Each instance of this class belongs to one instance of the Notification model class.

...

  • templateMappingSchema() (not shown) this is a custom function created by LiteFarm to define how one would treat properties and relations of the models graph in a duplication context (example: repeat management plans)

In jsonSchema(), the spread syntax expression ...this.baseProperties is used to include the base model's "base properties" in validating instances of class NotificationUser. These properties are "bookkeeping" columns found in most LiteFarm database tables. They record who created the row, and when; who last updated the row, and when; and whether the row is marked as "deleted". (In most cases, LiteFarm performs "soft deletes"; rather than actually deleting a database row, the row is marked as deleted, and excluded from further use.)

...

When one or more functions are listed before the final route handler function, these functions are called "middleware". The middleware function checkUserFarmStatus is called for both of the endpoints defined above.

Code Block
languagejs
const userFarmModel = require('../../models/userFarmModel');

const checkUserFarmStatus = (status = 'Active') => async (req, res, next) => {
  const { user_id, farm_id } = req.headers;
  const userFarm = await userFarmModel.query().where({ user_id, farm_id }).first();
  return userFarm && userFarm.status === status ? next() : res.status(403).send('Do not have access to this farm');
}

module.exports = checkUserFarmStatus;

Like a route handler, middleware functions receive Express's req and res objects as arguments. They also receive an argument named next, which represents the next function listed for the endpoint. A middleware function can choose to call that next function or not; this means it can permit control to continue through the list of functions, or interrupt the flow.

For example, the middleware function above queries the Objection userFarmModel class to determine if the user making this request is an authorized user for the farm identified in the request. If so, the middleware calls next() which activates the route handler that we have already discussed. But if the user is not authorized for the farm, the middleware does not use next to activate the route handler. Instead, it handles the response itself by sending an HTTP status code 403 ("Forbidden") and a description of the problem.

LiteFarm generally follows the REST conventions for endpoints and status codes used in responses.

Backend tests

The packages/api/tests folder contains tests of the API built with Jest. From a terminal in the packages/api folder, you can use the command npm test to run all these tests. Or, npx jest --runInBand tests/notificationUser.test.js will run a single file of tests-- in this case, the tests for the code we have explored. Here is an excerpt.

Code Block
languagejs
const mocks = require('./mock.factories');

describe('Notification tests', () => {
  function getRequest(url, { user_id = user.user_id, farm_id = farm.farm_id }, callback) {
    chai.request(server).get(url).set('user_id', user_id).set('farm_id', farm_id).end(callback);
  }

  let user;
  let farm;
  let userFarm;

  beforeEach(async () => {
    [user] = await mocks.usersFactory();
    [farm] = await mocks.farmFactory();
    [userFarm] = await mocks.userFarmFactory({ promisedUser: [user], promisedFarm: [farm] });

    const middleware = require('../src/middleware/acl/checkJwt');
    middleware.mockImplementation((req, res, next) => {
      req.user = {};
      req.user.user_id = req.get('user_id');
      next();
    });
  });

  describe('GET user notifications', () => {
    test('Users should get their notifications scoped for their current farm', async (done) => {
      const [notification] = await mocks.notification_userFactory({
        promisedUserFarm: [userFarm],
      });
      getRequest('/notification_user', {}, (err, res) => {
        expect(err).toBe(null);
        expect(res.status).toBe(200);
        expect(res.body.length).toBe(1);
        expect(res.body[0].user_id).toBe(user.user_id);
        expect(res.body[0].notification_id).toBe(notification.notification_id);
        done();
      });
    });

beforeEach configures the Jest framework to run the setup needed before each individual test. describe is used to organize groups of tests. test defines an individual test with an English description and a function that receives done. The argument is a callback function that the test must call when it has finished.

The test shown relies on database contents created by beforeEach. The userFarm object contains details of these database contents, and the test passes it to notification_userFactory to create additional database content specific to this test. This establishes known database contents as a basis for testing data retrieval by the API route "under test". (The mocks collection defines "factory" functions for most LiteFarm database record types. These support testing by creating database records as needed.)

The test then uses a helper function to make a GET request to the URL for retrieving the user's notifications for their current farm. This executes the code under test, which we expect to retrieve known data values. When the request completes, Jest runs the anonymous function that receives two arguments representing any error, and the API response.

expect calls are the actual testing; they describe the expected results. Conditional logic is embedded within expect so that any unsatisfied expectations will cause the test to fail. If all expectations are met and the code calls done(), then the test passes.

The output below includes the English provided to the describe and test calls above. The second green check mark indicates that the test shown in the excerpt has passed.

...

languagejs
const userFarmModel = require('../../models/userFarmModel');

const checkUserFarmStatus = (status = 'Active') => async (req, res, next) => {
  const { user_id, farm_id } = req.headers;
  const userFarm = await userFarmModel.query().where({ user_id, farm_id }).first();
  return userFarm && userFarm.status === status ? next() : res.status(403).send('Do not have access to this farm');
}

module.exports = checkUserFarmStatus;

Like a route handler, middleware functions receive Express's req and res objects as arguments. They also receive an argument named next, which represents the next function listed for the endpoint. A middleware function can choose to call that next function or not; this means it can permit control to continue through the list of functions, or interrupt the flow.

For example, the middleware function above queries the Objection userFarmModel class to determine if the user making this request is an authorized user for the farm identified in the request. If so, the middleware calls next() which activates the route handler that we have already discussed. But if the user is not authorized for the farm, the middleware does not use next to activate the route handler. Instead, it handles the response itself by sending an HTTP status code 403 ("Forbidden") and a description of the problem.

LiteFarm generally follows the REST conventions for endpoints and status codes used in responses.

Backend tests

The packages/api/tests folder contains tests of the API built with Jest. You can read more about this here.