Skip to content

PropertyMe Example

The code below is an example of a process connecting to the PropertyMe API. PropertyMe uses its own OAuth 2.0 refresh token flow rather than MODLR's built-in OAuth 2.0 flow, so this is an Advanced Integration.

INFO

This process requires a PropertyMe Client ID and Client Secret, stored as the Model Variables property-me-client-id and property-me-client-secret. It also expects a row already present in a propertyme.connections table (matched by connectionName) holding a valid access/refresh token pair - this class only reads and refreshes that token, it doesn't perform the initial authorization.

Library Code

A client for the PropertyMe API. It loads the current access/refresh token from the datastore, automatically refreshes it (and persists the refreshed token) on a 401, and retries once on a 500. Save this as a Process Library and load it with script.library.

js
// https://app.propertyme.com/api/swagger-ui/
class PropertyMeApi {
    /**
     * @param {string} connectionName
     * @param {string} [baseUrl="https://app.propertyme.com/api/v1/"] - The base URL of the API.
     */
    constructor(connectionName, baseUrl = "https://app.propertyme.com/api/v1/") {
        this.connectionName = connectionName;
        this.baseUrl = baseUrl;
        this.accessToken = null;
        this.refreshToken = null;
        this.expiresIn = null;

        this.CLIENT_ID = script.variableGet("property-me-client-id");
        this.CLIENT_SECRET = script.variableGet("property-me-client-secret");

        this._getTokens();
    }

    /**
     * Retrieves and sets the access token for the API.
     * @private
     * @throws Will throw an error if the token cannot be obtained.
     */
    _getTokens() {
        const query = `SELECT * FROM propertyme.connections WHERE name = ? AND state IS NULL`;

        const connections = JSON.parse(datasource.select("Internal Datastore", query, [this.connectionName]));
        const connection = connections && connections[0];
        if (!connection) {
            throw new Error(`Invalid connection name: '${this.connectionName}'`);
        }

        if (!connection.response) {
            throw new Error(`No tokens found with '${this.connectionName}', try renewing`);
        }

        const tokens = JSON.parse(connection.response);
        this.expiresIn = tokens.expires_in;
        this.accessToken = tokens.access_token;
        this.refreshToken = tokens.refresh_token;

        return tokens;
    }

    /**
     * Saves updated tokens back to the datastore.
     * @private
     * @param {object} tokens - The token object to save.
     */
    _saveTokens(tokens) {
        const updateQuery = `UPDATE propertyme.connections SET response = ? WHERE name = ? AND state IS NULL`;
        datasource.update("Internal Datastore", updateQuery, [JSON.stringify(tokens), this.connectionName]);
    }

    /**
     * Marks the connection as bad, so it's picked up for renewal.
     * @private
     */
    _setBadConnection() {
        const updateQuery = `UPDATE propertyme.connections SET response = null WHERE name = ?`;
        datasource.update("Internal Datastore", updateQuery, [this.connectionName]);
    }

    /**
     * Refreshes the access token using the refresh token.
     * @private
     * @throws Will throw an error if the token refresh fails.
     */
    _refreshToken() {
        if (!this.refreshToken) {
            throw new Error('No refresh token available');
        }

        const response = web.form(`https://login.propertyme.com/connect/token`, {
            'Content-Type': 'application/x-www-form-urlencoded',
        }, {
            "grant_type": "refresh_token",
            "refresh_token": this.refreshToken,
            "client_id": this.CLIENT_ID,
            "client_secret": this.CLIENT_SECRET,
        });

        if (response.status >= 200 && response.status < 300) {
            const tokens = JSON.parse(response.body);

            this.accessToken = tokens.access_token;
            this.refreshToken = tokens.refresh_token || this.refreshToken;
            this.expiresIn = Math.floor(Date.now() / 1000) + tokens.expires_in;

            this._saveTokens(tokens);
            return tokens;
        } else {
            this._setBadConnection();
            console.log(`Token refresh failed. Status ${response.status}. Body:`, response.body);
            throw new Error(`Token refresh failed with status ${response.status}`);
        }
    }

    /**
     * Checks if the current token is expired or near expiry. (They last 1 hour)
     * @private
     */
    _isTokenExpired() {
        if (!this.accessToken || !this.expiresIn)
            return true;

        const now = Math.floor(Date.now() / 1000);
        return now >= this.expiresIn - (60 * 5); // Refresh 5 minutes before expiry
    }

    /**
     * Makes an HTTP request to the PropertyMe API.
     * @private
     * @param {string} url - The endpoint URL (relative to the base URL).
     * @param {string} [method='GET'] - The HTTP method to use.
     * @param {object|null} [body=null] - The request body for POST, PATCH, PUT, DELETE requests.
     * @param {number} [retryAttempt=0] - The number of times this request has been retried.
     * @returns {object} The API response.
     * @throws Will throw an error if the request fails or if there is a server error.
     */
    _fetch(url, method = 'GET', body = null, retryAttempt = 0) {
        const absoluteUrl = `${this.baseUrl}/${url}`;
        const headers = {
            "Authorization": `Bearer ${this.accessToken}`,
            "Accept": "application/json",
        };

        const response = web.request(method, absoluteUrl, headers, body ? JSON.stringify(body) : null, false);

        if (response.status === 401 && retryAttempt === 0) {
            console.log("Possible access token expired, attempting to refresh...");
            this._refreshToken();
            return this._fetch(url, method, body, retryAttempt + 1);
        }

        if (response.status === 500 && retryAttempt < 3) {
            console.log(`Server error (500), retrying after 1 second... (attempt ${retryAttempt + 1}/3)`);
            script.sleep(1000);
            return this._fetch(url, method, body, retryAttempt + 1);
        }

        if (response.status >= 200 && response.status < 300) {
            return JSON.parse(response.body);
        } else {
            console.log(`Request failed. Status ${response.status}. Body:`, response.body);
            this._setBadConnection();
            throw new Error(`Request failed with status ${response.status}`);
        }
    }

    fetch(url, method = 'GET', body = null) {
        return this._fetch(url, method, body);
    }
}

Process Code

js
script.library("api/propertyme.js");

function pre() {
    // This function is called once before the process is executed.
    // Use this to setup prompts.
    script.log('process pre-execution parameters parsed.');
}

function begin() {
    // This function is called once at the start of the process
    script.log('process execution started.');

    try {
        const api = new PropertyMeApi("CONNECTOR_NAME");

        // See the PropertyMe Swagger docs for available endpoints.
        const properties = api.fetch("properties");
        console.log(properties);
    } catch (e) {
        console.log("Something went wrong");
        console.log(e);
    }
}

function data(record) {
    // This function is called once for each line of data on the second cycle
    // Use this to build dimensions and push data into cubes
}

function end() {
    // This function is called once at the end of the process
    script.log('process execution finished.');
}