Skip to content
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ it's just targetting Deno as a runtime (Typescript, URL imports, fetch, etc).
* `v1UsageMetering`: get billable summary, get top custom metrics
* `v2Roles`: list and describe roles & permissions
* `v2Users`: list, search, and describe datadog users
* `v2Teams`: list, search, and describe datadog teams

If you want a different API not listed here,
please open a Github issue or PR into `v1/` or `v2/` as appropriate.
Expand Down
6 changes: 6 additions & 0 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import v1EventsApi from "./v1/events.ts";

import v2RolesApi from "./v2/roles.ts";
import v2UsersApi from "./v2/users.ts";
import v2TeamsApi from "./v2/teams.ts";

// subset of Deno.env
interface EnvGetter {
Expand Down Expand Up @@ -126,4 +127,9 @@ export default class DatadogApi extends ApiClient {
get v2Users(): v2UsersApi {
return new v2UsersApi(this);
}

/** Get info about Teams. */
get v2Teams(): v2TeamsApi {
return new v2TeamsApi(this);
}
}
112 changes: 110 additions & 2 deletions v2/lib/identity.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
type TODO = unknown;

export interface ResultPage<T> {
export interface UsersResultPage<T> {
meta: {
page: {
total_filtered_count: number;
Expand All @@ -10,10 +10,37 @@ export interface ResultPage<T> {
data: Array<T>;
}

export interface Included {
export interface TeamsResultPage<T> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did datadog really need to add another pagination structure within v2 😅 LGTM

links: {
first: string | null;
last: string | null;
next: string | null;
prev: string | null;
self: string;
};
meta: {
pagination: {
first_offset: number;
last_offset: number;
limit: number;
next_offset: number;
offset: number;
prev_offset: number;
total: number;
type: string;
}
}
data: Array<T>;
}

export interface UsersIncluded {
included: Array<Role | Permission | User | Organization>;
}

export interface TeamsIncluded {
included: Array<User | TeamLink | UserTeamPermission>;
}

export type Ref<T extends string> = {
id: string;
type: T;
Expand All @@ -38,6 +65,7 @@ export interface Role {
}

export type UserStatus = "Active" | "Pending" | "Disabled";

export interface User {
type: "users";
id: string;
Expand Down Expand Up @@ -112,3 +140,83 @@ export interface UserInvitation {
uuid: string;
};
}

export interface TeamLink {
type: "team_links";
id: string;
attributes: {
label: string;
position: number;
team_id: string;
url: string;
}
}

export interface UserTeamPermission {
type: "user_team_permissions";
id: string;
attributes: Ref<"permissions">
}

export interface TeamPermissionSetting {
type: "team_permission_settings";
id: string;
attributes: {
action: "manage_membership" | "edit";
editable: boolean;
options?: Array<string>;
title: string;
value: "admins" | "members" | "organization" | "user_access_manage" | "teams_manage";
}
}

export type TeamFields = "users" | "team_links" | "user_team_permissions";

export interface Team {
type: "team";
id: string;
attributes: {
avatar?: string;
banner?: number;
created_at?: string; // iso date
description?: string;
handle: string;
hidden_modules?: Array<string>;
link_count?: number;
modified_at?: string; // iso date
name: string;
summary?: string;
user_count?: number;
visible_modules?: Array<string>;
};
relationships: {
team_links?: {
data: Array<Ref<"team_links">>;
links: {
related: string;
};
};
user_team_permissions?: {
data: Array<Ref<"user_team_permissions">>;
links: {
related: string;
};
};
users?: {
data: Array<Ref<"users">>
}
};
}

export interface TeamMembership {
type: "team_memberships";
id: string;
attributes: {
role: "admin"
};
relationships: {
user: {
data: Ref<"user">
}
}
}
157 changes: 157 additions & 0 deletions v2/teams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import type {
TeamsResultPage, TeamFields, TeamsIncluded, Team,
TeamMembership, TeamLink, TeamPermissionSetting
} from "./lib/identity.ts";

type TODO = unknown;

// Common API client contract
interface ApiClient {
fetchJson(opts: {
method: "GET" | "POST" | "DELETE";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this is the first DELETE call in this project, and so the new method needs to be also added to client.ts:

method?: 'GET' | 'POST';

path: string;
query?: URLSearchParams;
body?: unknown;
}): Promise<unknown>;
}

export default class DatadogTeamsApi {
#api: ApiClient;
constructor(api: ApiClient) {
this.#api = api;
}

/**
* Get the list of all users in the organization.
* This list includes all users even if they are deactivated or unverified.
*/
async listTeams(opts: {
page?: number;
per_page?: number;
sort?: {
field: "name" | "user_count";
order?: "asc" | "desc";
};
filterKeyword?: string;
filterMe?: boolean;
fieldsTeam?: Array<TeamFields>;
} = {}): Promise<TeamsResultPage<Team> & TeamsIncluded> {
const qs = new URLSearchParams();
if (opts.page != null) qs.set("page[number]", `${opts.page}`);
if (opts.per_page != null) qs.set("page[size]", `${opts.per_page}`);
if (opts.sort != null) qs.set("sort",
`${opts.sort.order == 'desc' ? '-' : ''}${opts.sort.field}`);
if (opts.filterKeyword != null) qs.set("filter[keyword]", `${opts.filterKeyword}`);
if (opts.filterMe != null) qs.set("filter[me]", `${opts.filterMe}`);
if (opts.fieldsTeam) qs.set("fields[team]", opts.fieldsTeam.join(','));

const json = await this.#api.fetchJson({
path: `/api/v2/team`,
method: "GET",
query: qs,
});
return json as TeamsResultPage<Team> & TeamsIncluded;
}

/** Get a team in the organization specified by the team’s team_id. */
async getTeam(teamId: string): Promise<{data: Team}> {
const json = await this.#api.fetchJson({
method: "GET",
path: `/api/v2/team/${encodeURIComponent(teamId)}`,
});
return json as {data: Team};
}

/** Get a list of members for a team */
async getTeamMemberships(teamId: string): Promise<TeamsResultPage<TeamMembership>> {
const json = await this.#api.fetchJson({
method: "GET",
path: `/api/v2/team/${encodeURIComponent(teamId)}/memberships`,
});
return json as TeamsResultPage<TeamMembership>;
}

/** List the links for a team */
async listTeamLinks(teamId: string): Promise<Array<TeamLink>> {
const json = await this.#api.fetchJson({
method: "GET",
path: `/api/v2/team/${encodeURIComponent(teamId)}/links`,
});
return json as Array<TeamLink>;
}

/** Get a single link for a team */
async getTeamLink(teamId: string, linkId: string): Promise<TeamLink> {
const json = await this.#api.fetchJson({
method: "GET",
path: `/api/v2/team/${encodeURIComponent(teamId)}/links/${encodeURIComponent(linkId)}`,
});
return json as TeamLink;
}

/** Get teams' permission settings */
async getTeamPermissionSettings(teamId: string): Promise<Array<TeamPermissionSetting>> {
const json = await this.#api.fetchJson({
method: "GET",
path: `/api/v2/team/${encodeURIComponent(teamId)}/permission-settings`,
});
return json as Array<TeamPermissionSetting>;
}

/** Create a team */
async createTeam(name: string): Promise<string> {
const words = [...name.toLowerCase().matchAll(/[a-z0-9]+/g)].map((x) => x[0])
const handle = words.join("-")
Comment on lines +102 to +104
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is slightly magic. Does this string manipulation follow a particular recommendation from datadog?
Continued:

Copy link
Author

@vandr0iy vandr0iy Dec 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oof. I'm afraid I leaked out some business logic from my script here 😅 refactoring in progress

const json = await this.#api.fetchJson({
method: "POST",
path: `/api/v2/team`,
body: {
data: {
type: "team",
attributes: {
name: name,
handle: handle
},
Comment on lines +111 to +114
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that there are additional possible attributes for this API, so perhaps we can accept a second optional parameter extraAttributes that lets the caller override handle and/or pass more attributes for the team.

Suggested change
attributes: {
name: name,
handle: handle
},
attributes: {
name: name,
handle: handle,
...extraAttributes,
},

relationships: {}
}
}
});
return (json as { status: string }).status;
}

/** Delete a team */
async deleteTeam(teamId: string): Promise<string> {
const json = await this.#api.fetchJson({
method: "DELETE",
path: `/api/v2/team/${encodeURIComponent(teamId)}`,
});
return (json as { status: string }).status;
}

/** Add a user to a team */
async addUserToTeam(teamId: string, userId: string): Promise<string> {

const json = await this.#api.fetchJson({
method: "POST",
path: `/api/v2/team/${encodeURIComponent(teamId)}/memberships`,
body: {
data: {
type: "team_memberships",
id: teamId,
attributes: {
role: "admin"
},
relationships: {
user: {
data: {
type: "users",
id: userId
}
}
}
}
},
});
return (json as { status: string }).status;
}
}
25 changes: 17 additions & 8 deletions v2/users.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
ResultPage, Included,
Role, User, Permission,
TeamsResultPage, TeamMembership,
UsersResultPage, UsersIncluded,
User, Permission,
UserStatus,
UserInvitation,
} from "./lib/identity.ts";
Expand Down Expand Up @@ -34,7 +35,7 @@ export default class DatadogUsersApi {
};
filter?: string;
filterStatus?: Array<UserStatus>;
} = {}): Promise<ResultPage<User> & Included> {
} = {}): Promise<UsersResultPage<User> & UsersIncluded> {
const qs = new URLSearchParams();
if (opts.page != null) qs.set("page[number]", `${opts.page}`);
if (opts.per_page != null) qs.set("page[size]", `${opts.per_page}`);
Expand All @@ -47,23 +48,23 @@ export default class DatadogUsersApi {
path: `/api/v2/users`,
query: qs,
});
return json as ResultPage<User> & Included;
return json as UsersResultPage<User> & UsersIncluded;
}

/** Get a user in the organization specified by the user’s user_id. */
async getUser(userId: string): Promise<{data: User} & Included> {
async getUser(userId: string): Promise<{data: User} & UsersIncluded> {
const json = await this.#api.fetchJson({
path: `/api/v2/users/${encodeURIComponent(userId)}`,
});
return json as {data: User} & Included;
return json as {data: User} & UsersIncluded;
}

/** Returns the user information and all organizations joined by this user. */
async getUserOrgs(userId: string): Promise<{data: User} & Included> {
async getUserOrgs(userId: string): Promise<{data: User} & UsersIncluded> {
const json = await this.#api.fetchJson({
path: `/api/v2/users/${encodeURIComponent(userId)}/orgs`,
});
return json as {data: User} & Included;
return json as {data: User} & UsersIncluded;
}

/** Returns a list of the user’s permissions granted by the associated user’s roles. */
Expand All @@ -82,4 +83,12 @@ export default class DatadogUsersApi {
return json as {data: UserInvitation};
}

/** Get a list of memberships for a user */
async getUserMemberships(userId: string): Promise<{data: TeamsResultPage<TeamMembership>}> {
const json = await this.#api.fetchJson({
path: `/api/v2/users/${encodeURIComponent(userId)}/memberships`,
});
return json as {data: TeamsResultPage<TeamMembership>};
}

}