Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"mongodb-log-writer": "^2.4.1",
"mongodb-redact": "^1.1.6",
"mongodb-schema": "^12.6.2",
"openapi-fetch": "^0.13.5",
"yargs-parser": "^21.1.1",
"zod": "^3.24.2"
},
Expand Down
1 change: 1 addition & 0 deletions scripts/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function filterOpenapi(openapi: OpenAPIV3_1.Document): OpenAPIV3_1.Document {
"getProject",
"createProject",
"listClusters",
"getCluster",
"createCluster",
"listClustersForAllProjects",
"createDatabaseUser",
Expand Down
204 changes: 110 additions & 94 deletions src/common/atlas/apiClient.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,7 @@
import config from "../../config.js";
import createClient, { Middleware } from "openapi-fetch";

import {
Group,
PaginatedOrgGroupView,
PaginatedAtlasGroupView,
ClusterDescription20240805,
PaginatedClusterDescription20240805,
PaginatedNetworkAccessView,
NetworkPermissionEntry,
CloudDatabaseUser,
PaginatedApiAtlasDatabaseUserView,
} from "./openapi.js";
import { paths, ClusterDescription20240805, NetworkPermissionEntry, CloudDatabaseUser } from "./openapi.js";

export interface OAuthToken {
access_token: string;
Expand Down Expand Up @@ -48,32 +39,40 @@ export interface ApiClientOptions {
}

export class ApiClient {
token?: OAuthToken;
saveToken?: saveTokenFunction;
private token?: OAuthToken;
private saveToken?: saveTokenFunction;
private client = createClient<paths>({
baseUrl: config.apiBaseUrl,
headers: {
"User-Agent": config.userAgent,
Accept: `application/vnd.atlas.${config.atlasApiVersion}+json`,
},
});
private authMiddleware = (apiClient: ApiClient): Middleware => ({
async onRequest({ request, schemaPath }) {
if (schemaPath.startsWith("/api/private/unauth") || schemaPath.startsWith("/api/oauth")) {
return undefined;
}
if (await apiClient.validateToken()) {
request.headers.set("Authorization", `Bearer ${apiClient.token?.access_token}`);
return request;
}
},
});
private errorMiddleware = (): Middleware => ({
async onResponse({ response }) {
if (!response.ok) {
throw new ApiClientError(`Error calling Atlas API: ${await response.text()}`, response);
Copy link
Collaborator

Choose a reason for hiding this comment

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

are we guaranteed the response text? is there a chance this could error as well?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

addressed

}
},
});

constructor(options: ApiClientOptions) {
const { token, saveToken } = options;
this.token = token;
this.saveToken = saveToken;
}

private defaultOptions(): RequestInit {
const authHeaders = !this.token?.access_token
? null
: {
Authorization: `Bearer ${this.token.access_token}`,
};

return {
method: "GET",
credentials: !this.token?.access_token ? undefined : "include",
headers: {
"Content-Type": "application/json",
Accept: `application/vnd.atlas.${config.atlasApiVersion}+json`,
"User-Agent": config.userAgent,
...authHeaders,
},
};
this.client.use(this.authMiddleware(this));
this.client.use(this.errorMiddleware());
}

async storeToken(token: OAuthToken): Promise<OAuthToken> {
Expand All @@ -86,36 +85,6 @@ export class ApiClient {
return token;
}

async do<T>(endpoint: string, options?: RequestInit): Promise<T> {
if (!this.token || !this.token.access_token) {
throw new Error("Not authenticated. Please run the auth tool first.");
}

const url = new URL(`api/atlas/v2${endpoint}`, `${config.apiBaseUrl}`);

if (!this.checkTokenExpiry()) {
await this.refreshToken();
}

const defaultOpt = this.defaultOptions();
const opt = {
...defaultOpt,
...options,
headers: {
...defaultOpt.headers,
...options?.headers,
},
};

const response = await fetch(url, opt);

if (!response.ok) {
throw new ApiClientError(`Error calling Atlas API: ${await response.text()}`, response);
}

return (await response.json()) as T;
}

async authenticate(): Promise<OauthDeviceCode> {
const endpoint = "api/private/unauth/account/device/authorize";

Expand Down Expand Up @@ -269,58 +238,105 @@ export class ApiClient {
}
}

async listProjects(): Promise<PaginatedAtlasGroupView> {
return await this.do<PaginatedAtlasGroupView>("/groups");
async listProjects() {
const { data } = await this.client.GET(`/api/atlas/v2/groups`);
return data;
}

async listProjectIpAccessLists(groupId: string): Promise<PaginatedNetworkAccessView> {
return await this.do<PaginatedNetworkAccessView>(`/groups/${groupId}/accessList`);
async listProjectIpAccessLists(groupId: string) {
const { data } = await this.client.GET(`/api/atlas/v2/groups/{groupId}/accessList`, {
params: {
path: {
groupId,
},
},
});
return data;
}

async createProjectIpAccessList(
groupId: string,
entries: NetworkPermissionEntry[]
): Promise<PaginatedNetworkAccessView> {
return await this.do<PaginatedNetworkAccessView>(`/groups/${groupId}/accessList`, {
method: "POST",
body: JSON.stringify(entries),
async createProjectIpAccessList(groupId: string, ...entries: NetworkPermissionEntry[]) {
const { data } = await this.client.POST(`/api/atlas/v2/groups/{groupId}/accessList`, {
params: {
path: {
groupId,
},
},
body: entries,
});
return data;
}

async getProject(groupId: string): Promise<Group> {
return await this.do<Group>(`/groups/${groupId}`);
async getProject(groupId: string) {
const { data } = await this.client.GET(`/api/atlas/v2/groups/{groupId}`, {
params: {
path: {
groupId,
},
},
});
return data;
}

async listClusters(groupId: string): Promise<PaginatedClusterDescription20240805> {
return await this.do<PaginatedClusterDescription20240805>(`/groups/${groupId}/clusters`);
async listClusters(groupId: string) {
const { data } = await this.client.GET(`/api/atlas/v2/groups/{groupId}/clusters`, {
params: {
path: {
groupId,
},
},
});
return data;
}

async listClustersForAllProjects(): Promise<PaginatedOrgGroupView> {
return await this.do<PaginatedOrgGroupView>(`/clusters`);
async listClustersForAllProjects() {
const { data } = await this.client.GET(`/api/atlas/v2/clusters`);
return data;
}

async getCluster(groupId: string, clusterName: string): Promise<ClusterDescription20240805> {
return await this.do<ClusterDescription20240805>(`/groups/${groupId}/clusters/${clusterName}`);
async getCluster(groupId: string, clusterName: string) {
const { data } = await this.client.GET(`/api/atlas/v2/groups/{groupId}/clusters/{clusterName}`, {
params: {
path: {
groupId,
clusterName,
},
},
});
return data;
}

async createCluster(groupId: string, cluster: ClusterDescription20240805): Promise<ClusterDescription20240805> {
if (!cluster.groupId) {
throw new Error("Cluster groupId is required");
}
return await this.do<ClusterDescription20240805>(`/groups/${groupId}/clusters`, {
method: "POST",
body: JSON.stringify(cluster),
async createCluster(groupId: string, cluster: ClusterDescription20240805) {
const { data } = await this.client.POST("/api/atlas/v2/groups/{groupId}/clusters", {
params: {
path: {
groupId,
},
},
body: cluster,
});
return data;
}

async createDatabaseUser(groupId: string, user: CloudDatabaseUser): Promise<CloudDatabaseUser> {
return await this.do<CloudDatabaseUser>(`/groups/${groupId}/databaseUsers`, {
method: "POST",
body: JSON.stringify(user),
async createDatabaseUser(groupId: string, user: CloudDatabaseUser) {
const { data } = await this.client.POST("/api/atlas/v2/groups/{groupId}/databaseUsers", {
params: {
path: {
groupId,
},
},
body: user,
});
return data;
}

async listDatabaseUsers(groupId: string): Promise<PaginatedApiAtlasDatabaseUserView> {
return await this.do<PaginatedApiAtlasDatabaseUserView>(`/groups/${groupId}/databaseUsers`);
async listDatabaseUsers(groupId: string) {
const { data } = await this.client.GET(`/api/atlas/v2/groups/{groupId}/databaseUsers`, {
params: {
path: {
groupId,
},
},
});
return data;
}
}
58 changes: 58 additions & 0 deletions src/common/atlas/openapi.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,28 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/atlas/v2/groups/{groupId}/clusters/{clusterName}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Return One Cluster from One Project
* @description Returns the details for one cluster in the specified project. Clusters contain a group of hosts that maintain the same data set. The response includes clusters with asymmetrically-sized shards. To use this resource, the requesting Service Account or API Key must have the Project Read Only role. This feature is not available for serverless clusters.
*
* This endpoint can also be used on Flex clusters that were created using the [createCluster](https://www.mongodb.com/docs/atlas/reference/api-resources-spec/v2/#tag/Clusters/operation/createCluster) endpoint or former M2/M5 clusters that have been migrated to Flex clusters until January 2026. Please use the getFlexCluster endpoint for Flex clusters instead. Deprecated versions: v2-{2023-02-01}, v2-{2023-01-01}
*/
get: operations["getCluster"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/atlas/v2/groups/{groupId}/databaseUsers": {
parameters: {
query?: never;
Expand Down Expand Up @@ -7467,6 +7489,42 @@ export interface operations {
500: components["responses"]["internalServerError"];
};
};
getCluster: {
parameters: {
query?: {
/** @description Flag that indicates whether Application wraps the response in an `envelope` JSON object. Some API clients cannot access the HTTP response headers or status code. To remediate this, set envelope=true in the query. Endpoints that return a list of results use the results object as an envelope. Application adds the status parameter to the response body. */
envelope?: components["parameters"]["envelope"];
/** @description Flag that indicates whether the response body should be in the prettyprint format. */
pretty?: components["parameters"]["pretty"];
};
header?: never;
path: {
/** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access.
*
* **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */
groupId: components["parameters"]["groupId"];
/** @description Human-readable label that identifies this cluster. */
clusterName: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/vnd.atlas.2024-08-05+json": components["schemas"]["ClusterDescription20240805"];
};
};
401: components["responses"]["unauthorized"];
404: components["responses"]["notFound"];
409: components["responses"]["conflict"];
500: components["responses"]["internalServerError"];
};
};
listDatabaseUsers: {
parameters: {
query?: {
Expand Down
2 changes: 1 addition & 1 deletion src/tools/atlas/createAccessList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class CreateAccessListTool extends AtlasToolBase {

const inputs = [...ipInputs, ...cidrInputs];

await this.apiClient.createProjectIpAccessList(projectId, inputs);
await this.apiClient.createProjectIpAccessList(projectId, ...inputs);

return {
content: [
Expand Down
2 changes: 1 addition & 1 deletion src/tools/atlas/inspectAccessList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export class InspectAccessListTool extends AtlasToolBase {

const accessList = await this.apiClient.listProjectIpAccessLists(projectId);

if (!accessList.results?.length) {
if (!accessList?.results?.length) {
throw new Error("No access list entries found.");
}

Expand Down
Loading