Skip to content

English__Theory__URL Building Functions

Ramirez Vargas, José Pablo edited this page Nov 7, 2024 · 5 revisions

URL-Building Functions

One of the greatest features of wj-config is its automatic URL-building functions. The package adds to the final configuration object functions that, when called, return properly constructed relative, absolute or full URL's using the data found in the provided configuration data sources.

This feature relieves the developer from all forms of string concatenation chores to create proper URL's, including any need for URL encoding.

Including URL-Building Functions

Adding URL functions is a two-part job. The easiest part is to request the URL-building functions during configuration construction, which is shown in the following example:

import wjConfig, { buildEnvironment } from 'wj-config';
import mainConfig from './config.json' assert { type: 'json' };

const myEnvs = [ 'Dev', 'PreProd', 'Prod' ];

const env = buildEnvironment(process.env.NODE_ENV, myEnvs);

const config = await wjConfig()
    .includeEnvironment(env)
    .addObject(mainConfig).name('Main')
    .addPerEnvironment((b, e) => b.addObject(loadJsonFile(`./config.${e}.json`)))
    .addEnvironment(process.env, 'APP_')
    .createUrlFunctions('ws')
    .build();

export default config;

Just by adding the line .createUrlFunctions(<property>), the URL functions will be built and will be made available for use through the final configuration object.

But I suppose this doesn't make much sense right now. How does the package know where to find the URL information?, or how is the information actually specified? How does it know how many URL functions are needed?

The answer to all these questions are revealed in the following sections where the second part of the job is thoroughly explained: How to specify URL data.

The Structure of the URL Configuration Data

It is a common practice to specify URL's through the application's configuration system because, normally, at least part of the data needed to build the URL's is environment-specific. For example, it is a very common thing to require a different host name across the different environments.

URL-building functions use the information found in very specific sections of the final configuration object to work its magic.

When createUrlFunctions() is called, one must specify the property or property names in the configuration object that contains all URL information. This means that the builder expects that all properties specified in its first argument are objects, and that sub-objects found in its properties may or may not contain particular properties that trigger the creation of the URL-building functions.

That's a mouthful. Let's disect this finer.

Reserved Properties for URL-Building

The following are reserved properties while in the context of URL-building functions (when the properties are direct properties of the objects specified in the call to createUrlFunctions(), or any sub-objects in their hierarchies):

Property Data Type Default Description
host String Desired host name. When present, full URL's are created.
scheme String http URL scheme.
port Number Port number. Don't specify if the URL is meant to use the scheme's default port number.
rootPath String Optional root path. Despite having "root" in its name, is relative to its position in the hierarchy.

Any of these properties will trigger the creation of URL-building functions starting at the node where the property(ies) is(are) first found. All sub-nodes will have this node as its URL root node. This is important: URL-building assumes incremental, hierarchical construction of URL's.


Ok, great. Now we know how to trigger URL-building function creation. Let's apply it in an example:

{
    "ws": {
        "externalApis": {
            "flags": {
                "host": "countryflagsapi.com",
                "scheme": "https"
            },
            "countries": {
                "host": "restcountries.com",
                "scheme": "https"
            }
        }
    }
}

The JSON above fulfills what we have learned so far:

  1. It defines the ws root property where we want to specify URL data.
  2. There are 2 sub-objects in its (ws's) hierarchy, each using the special host and scheme properties.

NOTE: The example has added the externalApis intermediate object merely to demonstrate that the special properties do not need to be part of the ws property or an immediate child of ws. The configuration hierarchy is yours. Define it as it best fits you.

If we were to use this JSON as data source, the final configuration object would look like this:

{
    ws: {
        externalApis: {
            flags: {
                host: 'countryflagsapi.com',
                scheme: 'https',
                buildUrl: function() { ... }
            },
            countries: {
                host: 'restcountries.com',
                scheme: 'https',
                buildUrl: function () { ... }
            }
        }
    }
}

Success! The buildUrl() function is the heart of the feature. It is not meant to be used directly, though, and is the topic for dynamic URL building near the end of this document.

So what's next? Define specific URL's. Let's ammend the configuration JSON:

{
    "ws": {
        "externalApis": {
            "flags": {
                "host": "countryflagsapi.com",
                "scheme": "https",
                "home": ""
            },
            "countries": {
                "host": "restcountries.com",
                "scheme": "https",
                "home": ""
            }
        }
    }
}

We have now added a third property to our sub-objects. This property name is not special in any way, and we could have used pretty much any other name, such as start or dog.

Whenever a property of type string that is not a reserved property is found within the URL root node or any sub-node is automatically transformed to a URL-building function that uses its string value as path segment. Because our example provides empty strings, the path is, well, nothing, and this means that the functions will be returning the website's homepage or home URL: https://countryflagsapi.com and https://restcountries.com.

Let's see how the final configuration object looks like for this ammended configuration JSON:

{
    ws: {
        externalApis: {
            flags: {
                host: 'countryflagsapi.com',
                scheme: 'https',
                buildUrl: function() { ... },
                home: function() { ... }
            },
            countries: {
                host: 'restcountries.com',
                scheme: 'https',
                buildUrl: function () { ... },
                home: function() { ... }
            }
        }
    }
}

Success, again! Now we have automatic URL-building functions that return the site's homepage:

import config from './config.js';

const flagsHomeUrl = config.ws.externalApis.flags.home();
const countriesHomeUrl = config.ws.externalApis.countries.home();

This is just the beginning. We can add many more.

DISCLAIMER: The following URL's don't actually exist in the domains chosen for the examples.

{
    "ws": {
        "externalApis": {
            "flags": {
                "host": "countryflagsapi.com",
                "scheme": "https",
                "home": "",
                "about": "/about"
            },
            "countries": {
                "host": "restcountries.com",
                "scheme": "https",
                "home": "",
                "support": "/support"
            }
        }
    }
}

With this new change, we would have the about() function that returns the https://countryflagsapi.com/about URL, and the support() function that returns https://restcountries.com/support. Are you getting the rythm yet? Let's see a more complex JSON, focusing on only one of the sub-objects:

{
    "ws": {
        "externalApis": {
            "countries": {
                "host": "restcountries.com",
                "scheme": "https",
                "home": "",
                "api": {
                    "v2": {
                        "rootPath": "/v2",
                        "all": "/all"
                    },
                    "v3": {
                        "rootPath": "/v3.1",
                        "all": "/all"
                    }
                }
            }
        }
    }
}

We are saying that the countries external service has version 2 and 3 of their API, and that all version-2 API's contain in their path the segment /v2 by defining the reseved rootPath property. A similar explanation can be stated for version-3 API's. The result of those functions would be:

import config from './config.js';

const countriesV2AllUrl = config.ws.externalApis.countries.api.v2.all();
const countriesV3AllUrl = config.ws.externalApis.countries.api.v3.all();
console.log(countriesV2AllUrl); // https://restcountries.com/v2/all
console.log(countriesV3AllUrl); // https://restcountries.com/v3.1/all

All this is achieved while fully allowing data sources to change any part of the configuration as per the usual configuration functionality you have learned so far. Hosts, schemes, ports, root paths and even individual URL paths can all be overridden as per the rules of data sources.

Furthermore, data is not repeated: The value of the rootPath property is inherited by all URL's created by functions in the level where it is specified, or any sub-level, and any sub-level may add path segments as needed.

In other words, this is URL configuration heaven: Need to move from API version 3.1 to version 3.2? It is changed in only one place, regardless of the number or URL's defined. Need to test drive version 4? Create a new sub-object for it, or create a data source that overrides v3 with v4 paths. Anything you can imagine is probably possible with this kind of setup.

Keep reading. We still only covered the very basics of this functionality.

Configuring Full, Absolute and Relative URL's

Server-sided JavaScript usually needs full URL's, while browser-sided JavaScript usually requires relative or absolute URL's. The exception is when writing a NodeJS web server that creates dynamic HTML. In this case, one probably needs absolute or relative URL's.

To configure full URL's, specify the host property. The scheme and port properties are ignored if there is no host specification.

To create absolute or relative URL's, simply don't specify host, scheme or port. To trigger the URL-building function creation process you may add the rootPath value. If you do not need a root path defined, simply define rootPath with either an empty string or a single slash ('/').

Browser Only: Configuring Full URL's with location.hostname

Some browser applications may want to utilize the host name of the application but change the protocol. The typical example would be to start web sockets communication. We don't want to specify a host in configuration, but we want to specify the scheme to that of web sockets (wss).

Well, wj-config is prepared for this. Whenever your code is running in the browser and the scheme or port numbers are specified without a host, wj-config will create an absolute URL using the current host name read from window.location.hostname. Here's an example configuration:

{
    "ws": {
        "api": {
            "rootPath": "/api"
            "users": {
                "rootPath": "/users",
                "all": ""
            }
        },
        "sockets": {
            "scheme": "wss",
            "support": {
                "rootPath": "/support",
                "liveChat": "/chat"
            }
        }
    }
}

Assuming a host name of my.example.com, this configuration JSON will produce the following URL's:

import config from './config.js';

const allUsersUrl = config.ws.api.users.all(); // /api/users
const liveChatUrl = config.ws.sockets.support.liveChat(); // wss://my.example.com/support/chat

IMPORTANT: Once the URL root node has been identified by the presence of the host, scheme, port or rootPath properties, said properties will not re-trigger re-definition of the URL root node. This means that sub-nodes that declare host, scheme or port properties are ignored, and only rootPath values will work by being appended to the built URL's as additional path segments. This is why the example uses two sub-nodes in different branches.

URL's with Replaceable Route Values

Ok, so far the only URL examples have been static URL's, but especially with RESTful services, this is hardly the real world. Let's see how the URL-building functions can dynamically replace templates within the URL definition to create fully compliant URL's.

Defining Replaceable Route Values

The first thing to do is define URL's. We will use a ficticious RESTful api for this purpose.

The following would be a browser-sided (React, Vue, Preact, etc.) configuration that defines absolute URL's:

{
    "ws": {
        "api": {
            "rootPath": "/api",
            "users": {
                "rootPath": "/users",
                "all": "",
                "get": "/{id}"
            },
            "orders": {
                "rootPath": "/orders",
                "all": "",
                "get": "/{id}",
                "searchByUser": "?userId={userId}"
            }
        }
    }    
}

With this configuration JSON, we'll have the following functions at our disposal:

  • ws.api.users.all()
  • ws.api.orders.all()
  • ws.api.users.get()
  • ws.api.orders.get()
  • ws.api.orders.searchByUser()

If we were to log the results of those functions to the console, we would notice the following output for the last 3 functions:

/api/users/{id}
/api/orders/{id}
/api/orders?userId={userId}

These unusual-looking URL's are not what we want: What are these brace-enclosed pieces of text? These brace-enclosed strings of text are replaceable route values. How can we replace these with actual user and order identifiers, which is what we really need? Quite simply, actually. There are 3 ways to obtain the desired result: Pass an object that serves as dictionary, or pass a function that receives the replaceable route value name and returns the desired replacement value, or pass an array of values. Let's see an example:

import config from './config.js';

const userId = 123;
let getUserUrl = null;
// Pass an object to be used as dictionary:
getUserUrl = config.ws.api.users.get({ id: userId });
// Pass an array:
getUserUrl = config.ws.api.users.get([ userId ]);
// Pass a function:
getUserUrl = config.ws.api.users.get(n => {
    switch (n) {
        case 'id':
            return userId;
            break;
        default:
            throw new Error(`Unknown replaceable route value: ${n}`);
    }
});
// Or a function assuming there is only one replaceable route value:
getUserUrl = config.ws.api.users.get(() => userId);
console.log(getUserUrl); // /api/users/123 for all cases above.

Which method to use is entirely up to the consumer of the package; just choose whichever suits you best.

NOTE: While we call these replaceable route values, they may also appear in query strings as seen in the example for ws.api.orders.searchByUser.

Replacing the values like this creates URL-compliant URL's. You don't need to worry about the encoding yourself.

You may also have guessed that a single URL may have more than one replaceable value. Add the replaceable route values you need. This is no problem. Just make sure to properly name each one, and yes, you may use the same name in more than one place. You would do this to either get the same value in both places, or you could have custom logic in the replacement value function to deal with this duplication. All this is possible.

Defining the URL-Building Data Location

So far we have included all URL-building data under a single root property ws. One may specify any other name, or even any number of other names. This specification is done while configuring the configuration builder, especifically with the createUrlFunctions() function.

createUrlFunctions() accepts two parameters, the first of which must be used to specify where to look for URL-building data. We could simplify the JSON we did about the ficticious RESTful API this way:

{
    "api": {
        "rootPath": "/api",
        "users": {
            "rootPath": "/users",
            "all": "",
            "get": "/{id}"
        },
        "orders": {
            "rootPath": "/orders",
            "all": "",
            "get": "/{id}",
            "searchByUser": "?userId={userId}"
        }
    }
}

We pretty much stripped away the ws object and rooted the api object. You would then construct like this:

import wjConfig, { buildEnvironment } from 'wj-config';
import mainConfig from './config.json' assert { type: 'json' };

const myEnvs = [ 'Dev', 'PreProd', 'Prod' ];

const env = buildEnvironment(process.env.NODE_ENV, myEnvs);

const config = await wjConfig()
    .includeEnvironment(env)
    .addObject(mainConfig).name('Main')
    .addPerEnvironment((b, e) => b.addObject(loadJsonFile(`./config.${e}.json`)))
    .addEnvironment(process.env, 'APP_')
    .createUrlFunctions('api')
    .build();

export default config;

The argument to the first parameter for the createUrlFunctions() call can be a string, or it can be an array of strings if you have more than one section in your configuration that carries URL-building data.

Are you wondering about the use of the second parameter? If so, continue reading.

Defining the Replaceable Route Value Syntax

As seen in the previous example, a replaceable value looks like this: {<name>}. This is inspired by ASP.net, actually.

But maybe you don't like this. Maybe you want to use a different syntax, maybe as in NodeJS Express, which is something like this: :<name>. It is possible to accomplish this because replaceable route values are detected using a regular expression, and consumers of the package may specify a regular expression to detect some other syntax.

The only requirement for the regular expression is that its first capturing group returns the replaceable route value's name. To continue with the NodeJS Express syntax example, you could do this while constructing your configuration object:

import wjConfig, { buildEnvironment } from 'wj-config';
import mainConfig from './config.json' assert { type: 'json' };

const myEnvs = [ 'Dev', 'PreProd', 'Prod' ];

const env = buildEnvironment(process.env.NODE_ENV, myEnvs);

const config = await wjConfig()
    .includeEnvironment(env)
    .addObject(mainConfig).name('Main')
    .addPerEnvironment((b, e) => b.addObject(loadJsonFile(`./config.${e}.json`)))
    .addEnvironment(process.env, 'APP_')
    .createUrlFunctions(undefined, /:([a-zA-Z_]\w*)/)
    .build();

export default config;

As seen in the code, createUrlFunctions() accepts two parameters, and we use the second one to pass the desired regular expression. For the specific case of this example (NodeJS Express route value syntax), we must make sure the regular expression does not match the URL port specification, which happens to start with a colon (:) followed by numbers. This is why the regular expression presented here requires that the first character of the name be a letter or an underscore.

With the regular expression in place, our JSON configuration can be re-written like this:

{
    "ws": {
        "api": {
            "rootPath": "/api",
            "users": {
                "rootPath": "/users",
                "all": "",
                "get": "/:id"
            },
            "orders": {
                "rootPath": "/orders",
                "all": "",
                "get": "/:id",
                "searchByUser": "?userId=:userId"
            }
        }
    }    
}

Dynamic Query Strings

Another important part when dealing with URL creation is the need to add query string parameters for things like sorting, searching or limiting the number of results. Particularly, there is a query standard out there called OData which happens to be very popular.

So how can wj-config's automatic URL-building functions append optional query strings? The answer is as simple as with replaceable route values. The URL-building functions come equipped with a second parameter just for this.

There are two ways to use the functions' second parameter:

  • Pass a string or a function that returns a string.
  • Pass a dictionary object or a function that returns a dictionary object.

There are technical differences between the two, so this time your choice depends on what you have available to you.

Using Strings

When passing a string or a function that returns a string, no URL encoding is done for you. Instead, it is assumed that the returned string is already a collection of key/value pairs already concatenated according to the query string syntax (using & between value pairs and = between the key and value of individual pairs).

The string must not include the starting question mark (?) or a starting ampersand (&) and must have been properly URL-encoded.

This is the typical option when using Kendo UI's toODataString function. This function returns the OData query string ready to go. Pass it as is to the URL-building function and voilá, you got yourself a URL with an OData-compliant query string.

Using a Dictionary Object

Passing an object or a function that returns an object is very similar to how replaceable route values work with an object: Each of the object's key is the query string's key, and its value is, well, the query string's value. This process is repeated for every key in the object and the final result is appended to the final URL. This process may also be used in combination of query strings defined in configuration, like the one shown in the following example (searchByUser):

{
    "api": {
        "rootPath": "/api",
        "users": {
            "rootPath": "/users",
            "all": "",
            "get": "/{id}"
        },
        "orders": {
            "rootPath": "/orders",
            "all": "",
            "get": "/{id}",
            "searchByUser": "?userId={userId}"
        }
    }    
}

With this configuration JSON, we could do this:

import config from './config.js';

const userId = 123;
const searchUrl = config.api.orders.searchByUser(() => userId, { limit: 10 });
console.log(searchUrl); // /api/orders?userId=123&limit=10

It is important to note that by using a dictionary, both the key and the value are URL-encoded for you.

Dynamic URL's

There will be cases where you might want to create a URL out of data that is collected in runtime. In cases like this one, maybe you know (and oftentimes you do know) the initial part of the URL, like a domain name, or that you want something from, say, somewhere in the /api section of your RESTful service.

When this case appears, you may use the buildUrl() automatic URL-building function. This is a function found in every node in a URL-building data hierarchy, so you may use the version that closest fits your needs.

Imagine for example that a React component receives as parameter the type of entity it will be loading (users, purchase orders, available items, etc.). By typical REST URL conventions, the entity type is all you need to generate the URL from the base API route.

If you scroll up a little bit, you'll see a JSON that has an api node with the rootPath property. We can use the buildUrl() function of this node to generate the data-fetching URL:

import config from './config.js';

export default function MyComponent({ entity }) {
    useEffect(() => {
        const dataUrl = config.api.buildUrl(entity, undefined, { limit: 50, page: 1 });
        const loadData = async () => {
            const response = await fetch(dataUrl);
            const data = await response.json();
            setData(data);
        };
        loadData();
    }, []);
}

In the above code, dataUrl's value is /api/<entity>?limit=50&page=1, where <entity> is whatever is passed via props to the component.

buildUrl() also supports route value replacement and dynamic query strings. The parameters are just offset by one because, for buildUrl(), the first parameter takes the path that will be concatenated to whatever base path the function knows.

Note that the value passed as first parameter will not be URL encoded. Make sure to validate your input data before creating the URL.

Now you know almost everything there is to know about URL-building functions. The next section will describe how to include non-URL (but URL-related) data inside the URL-building data hierarchy.

Declaring non-URL Properties in the URL-Building Hierarchy

Sometimes it is desirable to pair configuration data with the URL it belongs to, such as timeouts, retry counts and other configuration values that are not used to build the URL's, but are useful when fetching the URL's.

Declaring Non-String Properties

As stated already, any property in a URL-building data hierarchy is converted to a URL-building function, but only if its data type is string. Therefore, any non-string property is left alone. This immediately allows you to define things like timeouts or retry counts in nodes because those are usually numbers:

{
    "api": {
        "rootPath": "/api",
        "timeout": 5000,
        "users": {
            "rootPath": "/users",
            "all": "",
            "get": "/{id}",
            "timeout": 2000
        },
        "orders": {
            "rootPath": "/orders",
            "all": "",
            "get": "/{id}",
            "searchByUser": "?userId={userId}",
            "timeout": 10000
        }
    }    
}

The above example has defined 3 timeout values at 3 different positions. The idea would be to apply the timeout value at the api level if no other (more specific) timeout value is found. The one found under users would override the one found in api and applies for all user-related URL's while being fetched. Similarly, the one found in orders apply to the URL's found in that node.

But what about individual URL configuration? That's tricky, but possible. One way to do this would look like this:

{
    "api": {
        "rootPath": "/api",
        "users": {
            "rootPath": "/users",
            "all": "",
            "get": {
                "url": "/{id}",
                "timeout": 1000,
                "retryCount": 3,
                "anonymous": true
            }
        },
        "orders": {
            "rootPath": "/orders",
            "all": "",
            "get": "/{id}",
            "searchByUser": "?userId={userId}"
        }
    }    
}

This version defines api.users.get as an object, so the URL-building function will now be api.users.get.url(); the other values are configuration values for the fetching operation related to this one URL.

I Really Need a String

In the cases where a string is definitely needed, the option is to start the property name with an underscore (_) because properties whose name starts with underscore are not converted to URL-building functions:

{
    "api": {
        "rootPath": "/api",
        "users": {
            "rootPath": "/users",
            "all": "",
            "get": "/{id}",
            "_spinnerText": "Fetching user data..."
        },
        "orders": {
            "rootPath": "/orders",
            "all": "",
            "get": "/{id}",
            "searchByUser": "?userId={userId}",
            "_spinnerText": "Fetching order data..."
        }
    }    
}

In this example, the properties named _spinnerText will not be converted to URL-building functions.

What Else Is There?

If you remember from the early explanations, URL-building function creation is a process that needs to be triggered by the presence of one of the four reserved properties (host, scheme, port or rootPath). If URL-building function creation hasn't been triggered, you are completely free to use string properties for non-URL data.

This is an example where a branch inside api is created for configuration values:

{
    "api": {
        "config": {
            "timeout": 5000,
            "spinnerText": "Loading data.  Please wait..."
        },
        "rest": {
            "rootPath": "/api",
            "users": {
                "rootPath": "/users",
                "all": "",
                "get": "/{id}"
            },
            "orders": {
                "rootPath": "/orders",
                "all": "",
                "get": "/{id}",
                "searchByUser": "?userId={userId}"
            }
        }
    }    
}

Because the config branch isn't itself a URL root node, and its parent is not a URL root node and doesn't have a parent URL root node, no properties will be converted under this config branch.

Another variant would be to accommodate configuration data above URL data:

{
    "api": {
        "timeout": 5000,
        "spinnerText": "Loading data.  Please wait...",
        "urls": {
            "rootPath": "/api",
            "users": {
                "rootPath": "/users",
                "all": "",
                "get": "/{id}"
            },
            "orders": {
                "rootPath": "/orders",
                "all": "",
                "get": "/{id}",
                "searchByUser": "?userId={userId}"
            }
        }
    }    
}

This variant stuffs the configuration properties as direct children of the api node, which is the node specified in the call to creatUrlFunctions(), but because itself is not a URL root node, no properties will be converted because URL-building function creation hasn't been triggered yet.

Clone this wiki locally