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
YggdrasilStatus
error 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-Cookie
header 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 Conduit server client must be called from a Yggdrasil context. This means that you must include it as a top-level function in your ctx, and call it via ygg.ctx.<the name of your conduit client>(). Please refer to the "Context" documentation for more information.
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 type 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 context object defines common context parameters passed to all Conduit API calls. */
context: {
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/"
};
Add your API type to YggdrasilServer
// Adding MyAPI here lets YggdrasilServer know what to expect so it
// can throw an error if you forget to implement a function.
export const DemoServer = new YggdrasilServer<AppType, MyAPI>();
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 { DemoServer } from "../server";
export const hello = DemoServer.defineAPI().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 * as api from "./api";
// This should ideally be defined outside your request handler
const DemoConduitServer = new YggdrasilConduitServer(DemoServer, api);
// 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 stored and called as a top-level method in your app's context so that the request's context can be bound to it. 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
}, {
// All Conduit clients MUST be defined here (as top-level methods).
// 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
});
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.
When an error occurs, a YggdrasilClientStatus
object will
be thrown. YggdrasilClientStatus
is nearly identical to
its server counterpart (YggdrasilStatus
), but without an
associated server context.
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
}