Conduit
Whenever you setup an API sever, you also need to setup a client to consume the interfaces you expose. Yggdrasil Server Conduit allows you to simply define functions that take and return JSON-serializable data, then it handles the transit for you. Conduit provides options for constructing a type-safe TypeScript client, providing your own fetcher, and more.
Requests and subrequests
Conduit supports both client-to-server requests and server-to-server requests. The latter case is useful particularly for modern server architectures that may need to make many subrequests to different internal services in order to respond to a single external request. Consider the following example:
The above example illustrates how a To-Do application might be setup to
respond to requests. When the user loads the To-Do application
(the "Initial Request" in the diagram), the application needs to get the
user's tasks in order to render a webpage with their data. To get the user's
tasks, the frontend server makes a Conduit request to an internal API
called getMyTasks(). The getMyTasks() API needs to authenticate the user
in order to load their data, so it makes a Conduit request to an internal
API called authConsumer(). Thus, the single request to the frontend
server triggers multiple subrequests internally.
Conduit makes these types of configurations easy by providing the following server-to-server features:
- When an uncaught
YggdrasilStatuserror is thrown from a Conduit request, it will be passed up and thrown in the client as well, allowing deeply nested errors to automatically "bubble up" to the end user.- In the example above, this means that if
authConsumer()throws aYggdrasilStatus, it will be thrown all the way up to the frontend server's Conduit client.
- In the example above, this means that if
- Conduit APIs are able to add headers to the final response. This is implemented by passing the headers up through Conduit response data. When a non-Conduit request is found, the headers are added to the actual response.
- In the example above, this allows
authConsumer()to append aSet-Cookieheader to the frontend's response rotate the user's tokens.
- In the example above, this allows
- Conduit APIs can define a set of context parameters common to all APIs. The required context is provided by the client when constructing the Conduit client and will be passed to all APIs called without the need to explicitly include them in each function call.
- In the example above, this allows the authentication server to expect context parameters such as
cookie,user_agent, andip_address. This allows the authentication server to read session cookies and perform audit logging.
- In the example above, this allows the authentication server to expect context parameters such as
Assumptions and limitations
- Your API must be defined as an object, optionally including nested objects, that can only include valid API functions.
- Each argument in your API's functions must be JSON-serializable.
- Your API's functions must be asynchronous (return a Promise).
- Your API function and nested object names must be safe to include in URL paths.
- Conduit type safety only occurs at compile time through TypeScript.
At runtime, the client constructs functions dynamically, so nothing stops it from making invalid requests.
For example, TypeScript would stop you from calling
client.this.path.does.not.exist(), but this would work at runtime for any arbitrary path (this example makes a request to/this/path/does/not/exist). - The Yggdrasil Conduit server-side client must be bound to a Yggdrasil context. The intention is for Conduit clients to
be top-level functions in your
ctxorconduitCtx, and called via ygg.ctx.<the name of your conduit client>(). If you need to create and use a Yggdrasil Conduit server-side client outside ofygg.ctx, please use.bind(ygg), being careful to follow the rules described in the "Context" documentation. - Yggdrasil Server Conduit uses case-sensitive URLs. If you redirect clients to normalize their requested URLs, you MUST ensure that any such normalization does not apply to Yggdrasil Server Conduit paths.
- If you use YggdrasilStatus arguments (
statusArgs), those MUST be JSON serializable, since Yggdrasil Server Conduit includes those in the serialized response when an YggdrasilStatus object is caught.
Implementation
Define your API
To use Conduit's type safety features, you'll need to define your API's types in a file that can be imported from the client. This may involve putting it in an NPM package, git submodule, etc.
// This defines the public types of your Conduit API.
export type MyApi = {
/** The client object defines your API functions available to the client. */
client: {
hello: (time: "morning" | "evening" | "afternoon" | "night") => Promise<string>;
goodbye: () => Promise<string>;
};
/** The apiCtx object defines common context parameters clients include in all Conduit API calls. */
apiCtx: {
name: string;
};
};
// It's recommended to define the client options here to ensure consistency.
// They can always be overridden if needed.
export const myConduitClientOptions: YggdrasilConduitClientOptions = {
baseURL: "https://yggdrasil.ns.jottocraft.com/my-api/conduit/"
};
Create your typed defineApi function
import { defineApi } from "@jottocraft/yggdrasil";
// You should create your YggdrasilServer somewhere here.
// This extends your API definition to include private server types.
export type MyApiServer = MyApi & {
/** The conduitCtx object defines common context parameters passed to all of your server's Conduit API implementations. **/
conduitCtx: {
name: string;
};
};
export const defineMyApi = defineApi<AppTypes, MyApiServer>;
Implement your API
Now, simply write functions that implement your API! Note that
in your server-side implementation, a YggdrasilConduitContext object
is added before your own arguments so you can utilize Yggdrasil
Server features in your handlers. YggdrasilConduitContext extends
YggdrasilContext with additional Conduit-specific features, such as
the ability to add headers to the upstream request.
import { defineMyApi } from "../server";
export const hello = defineMyApi().hello(async (ygg, time) => {
// Adds a sample header to the upstream request. If a web server is calling
// this API, this header will be sent not on the API response, but on the final
// web server's response given to the user.
ygg.upstreamHeaders.set["X-My-Header"] = "My-Value";
// Notice how `time` is a parameter, while `name` comes from the common Conduit API context.
return "Good " + time + " " + ygg.apiCtx.name + "!";
});
Re-export all the routes from a single file
export * from "./hello";
// repeat the above for each route file in your project
Handle requests
Once you have your API handlers defined, simply call YggdrasilConduitServer.handleRequest to serve your API:
import { MyApiServer } from "./server";
import * as api from "./api";
// This should ideally be defined outside your request handler
const DemoConduitServer = new YggdrasilConduitServer<AppTypes, MyApiServer>(DemoServer, api, {
// The base path tells the conduit server where to listen for incoming API calls.
conduitBasePath: "/conduit/",
// You must have a function that maps between apiCtx and conduitCtx.
// This can perform input sanitization, or do some other lookups/transformations
// to flesh out your server-side context. Please note that this runs before
// your YggdrasilConduitContext is created, so you can't set upstream headers
// from within this function.
prepareConduitCtx: async (ygg, apiCtx) => apiCtx
});
// Additional required setup omitted for brevity
// Inside DemoServer.handleRequest:
return DemoConduitServer.handleRequest(ygg);
Call your API from a client
From another Yggdrasil Server instance
To call a Conduit API from another Yggdrasil Server instance,
call createConduitClientSideClient() to create a client.
The client must be bound to your ygg context, ideally
included in your YggdrasilServer ctx for automatic binding.
See the "Context" documentation for more details.
import { MyApi } from "my-shared-library/conduit.ts";
import { createConduitServerSideClient } from "@jottocraft/yggdrasil";
AnotherDemoServer.handleRequest(request, async (ygg) => {
// Call the Conduit API
const greeting = await ygg.ctx.conduit().hello("morning");
// Do whatever you want based on the outcome
}, {
// Conduit clients MUST be defined here (as top-level methods) for
// automatic `ygg` binding.
// They can be named whatever you want.
conduit: createConduitServerSideClient<MyApi>({
// Common context about this request to send to MyApi
name: "jottocraft"
}, {
// Options (baseURL is required)
baseURL: "https://yggdrasil.ns.jottocraft.com/demo/conduit/"
})
// The rest of your app's context
}, {
// YggdrasilServer options
});
If you need to construct a Conduit client dynamically, or otherwise
keep the Conduit client somewhere outside ygg.ctx, you must bind
the ygg context manually when calling it:
const conduit = createConduitServerSideClient<MyApi>(/* ... */);
await conduit.apply(ygg).hello("morning");
Memoizing Conduit requests
Conduit requests can be memoized when called from another Yggdrasil Server instance.
Simply use the memoization option to set which functions can be memoized:
createConduitServerSideClient<MyApi>({ /* ... */ }, {
// Include other options here
memoization: {
hello: true
}
});
The memoization option uses the same shape as your Conduit API client, but instead of containing
functions, it contains booleans to mark which functions can be memoized. If you have related functions
in the same nested object, you can set the entire nested object to true in the memoization
object to enable memoization for all those functions. You can also set memoization
itself to true to enable memoization for everything. By default, nothing is memoized.
Memoization is applied on a per-request basis, meaning it only kicks in when the server makes duplicate Conduit requests that originate from the same top-level request.
From an external source
Call createConduitClientSideClient() to create a client
for use outside a Yggdrasil Server context. When you call a
Conduit API from this client, upstreamHeaders from the API will
be discarded, and you'll need to handle errors yourself.
import { MyApi } from "my-shared-library/conduit.ts";
import { createConduitClientSideClient } from "@jottocraft/yggdrasil";
async function fetchConduitData() {
const conduit = createConduitClientSideClient<MyApi>({
// Common context about this request to send to MyApi
name: "jottocraft"
}, {
// Options (baseURL is required)
baseURL: "https://yggdrasil.ns.jottocraft.com/demo/conduit/"
});
const greeting = await conduit.hello("morning");
// Do whatever you want based on the outcome
}