Type safety for requestProduct API interactions

What’s the best way in Forge to work with type safety when interacting with the product APIs?

A typical way is to use openapi-typescript and the openapi-fetch client, as demonstrated in this Vite Node Typescript script:

#!/usr/bin/env -S vite-node --script

import createClient from "openapi-fetch";

// Generated with "npx openapi-typescript https://developer.atlassian.com/cloud/jira/platform/swagger.v3.json"

import type { paths, components } from "schemas/jira-cloud-platform-api";

const client = createClient<paths>({ baseUrl: "https://ecosystem.atlassian.net" });

const { data, error } = await client.GET("/rest/api/2/project/{projectIdOrKey}/statuses", {params:{path:{projectIdOrKey:"FRGE"}}});

if (error) { throw new Error(error); }
if (!data || data.length == 0) { throw new Error("No data returned"); }

const issueTypesWithStatus: components["schemas"]["IssueTypeWithStatus"][] = data;

for (const issueType of issueTypesWithStatus) {
    console.log(`Issue type ${issueType.name} has ${issueType.statuses.length} statuses`);
}

This prints out:

Issue type Bug has 8 statuses
Issue type Suggestion has 8 statuses

I’m not wedded to the openapi-fetch but its typechecking has made scripting with Vite Node and testing with Vitest very easy, fast and accurate. My research indicates I can pass in a Fetch instance into the createClient function that uses the route tagging template and the requestJira, requestConfluence, requestBitbucket etc. functions, but that’s as far as I’ve been able to work it out.

How can I introduce typechecking in my Forge apps for product REST API interactions?

3 Likes

I don’t know about the integration on Forge requestJira and requestConfluence but there are multiples projects to generate Typescript types from Atlassian OpenAPI:

I have not used these projects, but we are doing more or less the same things internally.

In all cases, keep in mind that Atlassian OpenAPI schema is very often wrong, so you cannot 100% rely on it.

1 Like

Yes, typeguards are still necessary - the benefit of this approach is more the “what am I working with?” as Intellisense and similar can recognise the API calls and responses and guide you through writing your code.

I couldn’t leave this well enough alone. A custom fetch was the way. It turned out to be much simpler than I expected - almost too simple.

First, for client-side fetch, for example, a project settings page:

import React, { useEffect, useState } from 'react';
import ForgeReconciler, { Text } from '@forge/react';
import { RequestInit } from '@forge/api';
import { requestJira } from '@forge/bridge';
import createClient from "openapi-fetch";

import type { paths, components } from '../../schemas/jira-cloud-platform-api';

function asUserJiraFetch (input: Request, options?: RequestInit): Promise<Response> {
  const url = new URL(input.url);
  return requestJira(url.pathname, { 
    ...options, 
    method: input.method, 
    headers: { ...options?.headers, ...Object.fromEntries(input.headers) }
  });
};

const jiraUserClient = createClient<paths>({ fetch: asUserJiraFetch });


async function getProjectIssueTypesAsUser(projectId: string): Promise<components['schemas']['IssueTypeWithStatus'][]> {
  const { data, error } = await jiraUserClient.GET("/rest/api/3/project/{projectIdOrKey}/statuses", 
    { params: { path: { projectIdOrKey: projectId}}});
  if (error) { throw new Error(error); }
  if (!data || data.length === 0) { throw new Error(`No issue types in project ${projectId}`); }

  return data;
}

const App = () => {
  const context = useProductContext();
  const [asUserIssueTypes, setUserIssueTypes] = useState<components['schemas']['IssueTypeWithStatus'][] | null>(null);


  useEffect(() => {
    getProjectIssueTypesAsUser(context['extension']['project']['id'])
      .then((result) => {
        setUserIssueTypes(result as components['schemas']['IssueTypeWithStatus'][])
      });
  }, []);
  return (
    <>
      <Text>Project issue types</Text>
      <Text>{asUserIssueTypes ? `As a user, I see that this project has ${asUserIssueTypes.length} issue types.` : 'Loading...'}</Text>
    </>
  );
};
ForgeReconciler.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

For resolvers on the Forge backend, it’s much the same - however, because openapi-fetch manipulates the path, for example to bring in the {projectIdOrKey} path variable, Forge won’t accept the path with route tagging template and I need to use assumeTrustedRoute:

import Resolver from '@forge/resolver';
import api, { assumeTrustedRoute, RequestInit, APIResponse } from '@forge/api';
import createClient from "openapi-fetch";

import type { paths, components } from '../../schemas/jira-cloud-platform-api';

function asAppJiraFetch (input: Request, options?: RequestInit): Promise<Response> {
  const url = new URL(input.url);
  const apiResponse: Promise<APIResponse> = api.asApp().requestJira(
    assumeTrustedRoute(url.pathname), { ...options, method: input.method,
       headers: { ...options?.headers, ...Object.fromEntries(input.headers) }
    });

  return apiResponse as Promise<Response>
};

const jiraAppClient = createClient<paths>({
  baseUrl: "https://example.com/", // For fetch library compliance
  fetch:  asAppJiraFetch 
});

const resolver = new Resolver();

resolver.define('getProjectIssueTypesAsApp', async (req): Promise<components['schemas']['IssueTypeWithStatus'][]> => {

  if (!req.context || !req.context['extension']) {
    throw new Error("App context not found");
  }
  if (!req.context['extension']['project']['id']) {
    throw new Error("Project ID not found in app context");
  }
  const { data, error } = await jiraAppClient.GET("/rest/api/3/project/{projectIdOrKey}/statuses",
    { params: { path: { projectIdOrKey: req.context['extension']['project']['id']}}});
  if (error) { throw new Error(error); }
  if (!data || data.length === 0) { throw new Error(`No issue types in project ${req.context['extension']['project']['id']}`); }

  return data;

});

export const handler = resolver.getDefinitions();

Finally, I had to replace “/rest/api/2” in the Jira Cloud Platform OpenAPI schema with “/rest/api/3” to get it working with Forge’s requestJira fetch clients.

This approach should serve me in rapidly developing my Forge app. I’ll likely sometimes need to call the requestProduct interactions directly, forgoing typechecking, but I’m not prevented from doing that on a case-by-case basis.

1 Like