← back

JQL client with TS support

Hi there!

Lately I’ve been trying to automate some tedious parts of the release process at my job. Long story short we must verify that all commits point to right Jira issue and the release version matches.

To my surprise I couldn’t find existing JQL client library so I wrote my own and the best part is that it’s fully typed!

Problem

There’s a Jira REST API available that allows you to fetch issues with quite sophisticated query language called JQL. For example, we can fetch all the issues for a given list of keys (you can think of issue key as an alternative way to identify an issue, sort of like an ID) and ask only for a subset of fields, say "summary" and "description". The query would look like this:

ts
{
jql: `key IN (<key-1>, <key-2>, ..., <key-n>)`,
validateQuery: "strict",
fields: ["summary", "description"],
}
ts
{
jql: `key IN (<key-1>, <key-2>, ..., <key-n>)`,
validateQuery: "strict",
fields: ["summary", "description"],
}

and the result:

ts
{
issues: [
{
key: 'key-1',
fields: {
"summary": { '...' },
"description: { '...' },
}
},
{
key: 'key-2',
fields: {
"summary": { '...' },
"description: { '...' },
}
},
...
]
}
ts
{
issues: [
{
key: 'key-1',
fields: {
"summary": { '...' },
"description: { '...' },
}
},
{
key: 'key-2',
fields: {
"summary": { '...' },
"description: { '...' },
}
},
...
]
}

Given how flexible the query can be, we would like to reflect what fields are available in the resulting type, so that accessing fields we didn’t explicitly asked for would throw a type error.

So let’s get to work!

Code

First, let’s create the Issue and all the accompanying types. The tricky part is that the resulting type depends on the constructed query. Generics to the rescue!

We’ll create two type parameters E, a union of on-demand extends and F, a union of field names to filter. Then we’ll pass these two type variables starting from the client query() method where they will be inferred from the arguments passed by the developer and forward them down to the final Issue type.

ts
/** fields the query can fetch "on-demand" */
export type Expands =
| "names"
| "schema"
| "transitions"
| "editmeta"
| "changelog"
| "operations"
| "renderedFields"
;
/** builtin issue fields - I listed only the most important ones for brevity */
export type BuiltinFields = {
summary: string;
status: Status;
issuetype: IssueType;
fixVersions: FixVersion[];
labels: string[];
subtasks: Subtask[];
};
/**
* I'm not a Jira expert but from what I saw custom fields
* always start with `customfield_` prefix so let's validate it!
*/
export type CustomFieldName = `customfield_${number}`;
/** all available field names */
export type FieldName = keyof BuiltinFields | CustomFieldName;
/** and the Issue type itself */
export type Issue<
F extends FieldName = FieldName,
E extends Expands = never
> = Omit<
{
id: string;
key: string;
fields: Pick<IssueFields, F>;
// on-demand fields, filtered by outer `Omit<...>`
transitions: Transition[];
changelog: Changelog;
editmeta: { fields: Record<F, EditMeta> };
operations: Record<F, Operation>;
},
Exclude<
| "transitions"
| "changelog"
| "editmeta"
| "operations",
E
>
>;
ts
/** fields the query can fetch "on-demand" */
export type Expands =
| "names"
| "schema"
| "transitions"
| "editmeta"
| "changelog"
| "operations"
| "renderedFields"
;
/** builtin issue fields - I listed only the most important ones for brevity */
export type BuiltinFields = {
summary: string;
status: Status;
issuetype: IssueType;
fixVersions: FixVersion[];
labels: string[];
subtasks: Subtask[];
};
/**
* I'm not a Jira expert but from what I saw custom fields
* always start with `customfield_` prefix so let's validate it!
*/
export type CustomFieldName = `customfield_${number}`;
/** all available field names */
export type FieldName = keyof BuiltinFields | CustomFieldName;
/** and the Issue type itself */
export type Issue<
F extends FieldName = FieldName,
E extends Expands = never
> = Omit<
{
id: string;
key: string;
fields: Pick<IssueFields, F>;
// on-demand fields, filtered by outer `Omit<...>`
transitions: Transition[];
changelog: Changelog;
editmeta: { fields: Record<F, EditMeta> };
operations: Record<F, Operation>;
},
Exclude<
| "transitions"
| "changelog"
| "editmeta"
| "operations",
E
>
>;

now let’s take care of the JQL request:

ts
export type JQLRequest<F extends FieldName, E extends Expands> = Readonly<{
/** If not provided, Jira returns all available issues */
jql?: string;
startAt?: number;
maxResults?: number;
validateQuery?: "strict" | "warn" | "none";
/** Use it to retrieve a subset of fields */
fields?: readonly F[];
/** Use it to include additional information about issues */
expand?: readonly E[];
}>;
ts
export type JQLRequest<F extends FieldName, E extends Expands> = Readonly<{
/** If not provided, Jira returns all available issues */
jql?: string;
startAt?: number;
maxResults?: number;
validateQuery?: "strict" | "warn" | "none";
/** Use it to retrieve a subset of fields */
fields?: readonly F[];
/** Use it to include additional information about issues */
expand?: readonly E[];
}>;

and the response:

ts
export type JQLResult<F extends FieldName, E extends Expands> = Omit<
{
issues: Issue<F, E>[];
maxResults: number;
startAt: number;
total: number;
expand: string;
names: Record<F, string>;
schema: Record<F, FieldSchema>;
warningMessages?: string[];
},
Exclude<"names" | "schema", E>
>;
ts
export type JQLResult<F extends FieldName, E extends Expands> = Omit<
{
issues: Issue<F, E>[];
maxResults: number;
startAt: number;
total: number;
expand: string;
names: Record<F, string>;
schema: Record<F, FieldSchema>;
warningMessages?: string[];
},
Exclude<"names" | "schema", E>
>;

now having all types in place let’s assemble the client:

ts
export class JQLClient {
constructor(
baseURL: string,
username: string,
apiKey: string,
private readonly instance: AxiosInstance = axios.create({
baseURL,
timeout: 1000 * 15,
auth: { username, password: apiKey },
})
) {}
async query<F extends FieldName = FieldName, E extends Expands = never>(
request: JQLRequest<F, E>
) {
return this.instance.post<JQLResult<F, E>>("/rest/api/3/search", {
...request,
maxResults: request.maxResults ?? 50,
});
}
}
ts
export class JQLClient {
constructor(
baseURL: string,
username: string,
apiKey: string,
private readonly instance: AxiosInstance = axios.create({
baseURL,
timeout: 1000 * 15,
auth: { username, password: apiKey },
})
) {}
async query<F extends FieldName = FieldName, E extends Expands = never>(
request: JQLRequest<F, E>
) {
return this.instance.post<JQLResult<F, E>>("/rest/api/3/search", {
...request,
maxResults: request.maxResults ?? 50,
});
}
}

Usage example

Now I can perform JQL requests, specify what ‘on-demand’ extends or fields I want with full type support 🎉

ts
const client = new JQLClient(
'https://<your-company-namespace>.atlassian.net',
'username',
'secret-password',
);
const { data } = await client.query({
jql: `key IN (<some-issue-key>)`,
validateQuery: "strict",
expand: ["changelog"],
fields: ["summary", "fixVersions"],
});
const [issue] = data.issues;
console.log(issue.changelog) // 'Changelog'
console.log(issue.schema); // type error!
console.log(issue.fields.summary); // 'string'
console.log(issue.fields.description) // type error!
ts
const client = new JQLClient(
'https://<your-company-namespace>.atlassian.net',
'username',
'secret-password',
);
const { data } = await client.query({
jql: `key IN (<some-issue-key>)`,
validateQuery: "strict",
expand: ["changelog"],
fields: ["summary", "fixVersions"],
});
const [issue] = data.issues;
console.log(issue.changelog) // 'Changelog'
console.log(issue.schema); // type error!
console.log(issue.fields.summary); // 'string'
console.log(issue.fields.description) // type error!

Here are the types I didn’t include to make the code easier to read:

ts
type Status = any;
type IssueType = any;
type FixVersion = any;
type Subtask = any;
type Transition = any;
type Changelog = any;
type EditMeta = any;
type Operation = any;
type FieldSchema = any;
type IssueFields = {
[key in string]: any;
}
ts
type Status = any;
type IssueType = any;
type FixVersion = any;
type Subtask = any;
type Transition = any;
type Changelog = any;
type EditMeta = any;
type Operation = any;
type FieldSchema = any;
type IssueFields = {
[key in string]: any;
}

Summary

The next step? Type JQLRequest["jql"] to only allow valid queries, and make a proper library out of it, but that’s a topic for another blog post.

Until next time!