Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
12 changes: 12 additions & 0 deletions clients/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ const client = new PatchClientV3({
const plants = await client.getPlantList({ page: 0, size: 20 });
```

## Newly Added APIs

The latest OpenAPI sync also includes:

- `listOAuthMethods(query)`
- `getOAuth2LoginUrl(provider, redirectUrl?)`
- `listCombinerModelInfo()`
- `listInverterModelInfo()`
- `listModuleModelInfo()`
- `getDeviceState(plantId, date, kind)`
- `getPlantRegistryStat(plantId, date)`

## Also Usable from JavaScript

This package is authored in TypeScript, but distributed as JavaScript (`dist/*.js`),
Expand Down
224 changes: 206 additions & 18 deletions clients/typescript/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,56 @@ export type QueryValue =
*/
export type JsonObject = Record<string, unknown>;

export interface AuthProvider {
name: string;
state: string;
codeChallenge: string;
codeChallengeMethod: string;
authUrl: string;
}

export interface AuthMethodsBody {
$schema?: string;
authProviders: AuthProvider[] | null;
}

export interface ModelCatalogItem {
id: string;
[key: string]: unknown;
}

export interface CombinerItem extends ModelCatalogItem {}

export interface InverterItem extends ModelCatalogItem {}

export interface ModuleItem extends ModelCatalogItem {}

export interface ModelInfoListBody<TItem extends ModelCatalogItem = ModelCatalogItem> {
$schema?: string;
items: TItem[] | null;
}

export interface DeviceModelStat {
name: string;
count: number;
installed_capacity_w: number;
}

export interface StatModelCount {
name: string;
count: number;
}

export interface StatPoint {
$schema?: string;
timestamp: string;
installed_capacity_w: number;
module_models: StatModelCount[] | null;
device_models: DeviceModelStat[] | null;
}

export type DeviceStateKind = "seqnum" | "relay" | "rsd";

/**
* Client-wide configuration for {@link PatchClientV3}.
*/
Expand Down Expand Up @@ -295,6 +345,80 @@ export class PatchClientV3 {
return this.request("GET", "/api/v3/account/", { options });
}

/**
* Lists available OAuth2 authentication providers.
*/
async listOAuthMethods(
query?: { provider?: string; redirect_url?: string },
options?: RequestOptions
): Promise<AuthMethodsBody> {
return this.request("GET", "/api/v3/account/auth-methods", { query, options }) as Promise<AuthMethodsBody>;
}

/**
* Starts the OAuth2 login flow and returns the provider redirect URL.
*/
async getOAuth2LoginUrl(
provider: string,
redirectUrl?: string,
options?: RequestOptions
): Promise<string> {
const url = this.buildUrl("/api/v3/account/login-with-oauth2", {
provider,
redirect_url: redirectUrl,
});
const headers = mergeHeadersCaseInsensitive(
{ Accept: "application/json" },
this.defaultHeaders,
this.authHeaders(options),
options?.headers
);
Comment thread
cypark-conalog marked this conversation as resolved.
const init: FetchInit = { method: "GET", headers, redirect: "manual" };
const { signal, cleanup, timeoutSupported } = createRequestSignal(
options?.signal,
options?.timeoutMs
);
if (signal) {
init.signal = signal;
}

try {
const hasTimeout =
typeof options?.timeoutMs === "number" &&
Number.isFinite(options.timeoutMs) &&
options.timeoutMs > 0;
if (hasTimeout && !timeoutSupported) {
throw new Error("timeoutMs requires AbortController support in this runtime");
}

const response = await this.fetchFn(url.toString(), init);
const location = response.headers.get("location");
if (response.status >= 300 && response.status < 400 && location) {
return location;
}

const payload = await parseResponse(response, this.maxResponseBytes);
throw new PatchClientError(response.status, payload, undefined, {
method: "GET",
url: url.toString(),
});
} catch (err) {
if (err instanceof PatchClientError) {
throw err;
}
const networkError = new PatchClientError(
0,
null,
`PATCH API request failed: GET ${url.toString()}`,
{ method: "GET", url: url.toString() }
);
(networkError as Error & { cause?: unknown }).cause = err;
throw networkError;
} finally {
cleanup();
}
}

/**
* Creates a member under an organization.
*
Expand Down Expand Up @@ -464,6 +588,66 @@ export class PatchClientV3 {
});
}

/**
* Lists combiner model info rows from the asset catalog.
*/
async listCombinerModelInfo(
options?: RequestOptions
): Promise<ModelInfoListBody<CombinerItem>> {
return this.request("GET", "/api/v3/model-info/combiners", { options }) as Promise<
ModelInfoListBody<CombinerItem>
>;
}

/**
* Lists inverter model info rows from the asset catalog.
*/
async listInverterModelInfo(
options?: RequestOptions
): Promise<ModelInfoListBody<InverterItem>> {
return this.request("GET", "/api/v3/model-info/inverters", { options }) as Promise<
ModelInfoListBody<InverterItem>
>;
}

/**
* Lists module model info rows from the asset catalog.
*/
async listModuleModelInfo(options?: RequestOptions): Promise<ModelInfoListBody<ModuleItem>> {
return this.request("GET", "/api/v3/model-info/modules", { options }) as Promise<
ModelInfoListBody<ModuleItem>
>;
}

/**
* Retrieves device state indicator data.
*/
async getDeviceState(
plantId: string,
date: string,
kind: DeviceStateKind,
options?: RequestOptions
): Promise<unknown> {
return this.request("GET", `/api/v3/plants/${encodePath(plantId)}/indicator/device-state`, {
query: { date, kind },
options,
});
}

/**
* Retrieves daily registry statistics for a plant.
*/
async getPlantRegistryStat(
plantId: string,
date: string,
options?: RequestOptions
): Promise<StatPoint> {
return this.request("GET", `/api/v3/plants/${encodePath(plantId)}/registry/stat`, {
query: { date },
options,
}) as Promise<StatPoint>;
}

/**
* Lists inverter logs for a plant.
*
Expand Down Expand Up @@ -615,24 +799,7 @@ export class PatchClientV3 {
}

private async request(method: string, path: string, input: RequestInput = {}): Promise<unknown> {
const url = new URL(this.baseUrl);
const basePath = url.pathname.endsWith("/") ? url.pathname.slice(0, -1) : url.pathname;
url.pathname = `${basePath}${path}`;
const query = input.query ?? {};
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
for (const item of value) {
if (item !== undefined && item !== null) {
url.searchParams.append(key, String(item));
}
}
} else {
url.searchParams.set(key, String(value));
}
}
const url = this.buildUrl(path, input.query);

const headers = mergeHeadersCaseInsensitive(
{ Accept: "application/json" },
Expand Down Expand Up @@ -747,6 +914,27 @@ export class PatchClientV3 {

return headers;
}

private buildUrl(path: string, query?: Record<string, QueryValue>): URL {
const url = new URL(this.baseUrl);
const basePath = url.pathname.endsWith("/") ? url.pathname.slice(0, -1) : url.pathname;
url.pathname = `${basePath}${path}`;
for (const [key, value] of Object.entries(query ?? {})) {
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
for (const item of value) {
if (item !== undefined && item !== null) {
url.searchParams.append(key, String(item));
}
}
} else {
url.searchParams.set(key, String(value));
}
}
return url;
}
}

function encodePath(value: string): string {
Expand Down
11 changes: 11 additions & 0 deletions clients/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@ export {
PatchClientError,
PatchClientV3,
type AccountType,
type AuthMethodsBody,
type AuthProvider,
type ClientConfig,
type CombinerItem,
type DeviceModelStat,
type DeviceStateKind,
type InverterItem,
type ModelCatalogItem,
type ModelInfoListBody,
type ModuleItem,
type RequestOptions,
type StatModelCount,
type StatPoint,
} from "./client";
13 changes: 13 additions & 0 deletions clients/typescript/test-types/client-signatures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ void client.getMetricsByDate(
"2024-01-24",
{ before: 1, fields: ["i_out", "p"] }
);
void client.listOAuthMethods({ provider: "google", redirect_url: "myscheme://callback" });
void client.getOAuth2LoginUrl("google", "myscheme://callback");
void client.listCombinerModelInfo();
void client.listInverterModelInfo();
void client.listModuleModelInfo();
void client.getDeviceState("unw4id41ud2p0wt", "2024-01-24", "seqnum");
void client.getPlantRegistryStat("unw4id41ud2p0wt", "2024-01-24");

// @ts-expect-error includeState must be boolean
void client.getLatestDeviceMetrics("unw4id41ud2p0wt", { includeState: "true" });
Expand All @@ -20,3 +27,9 @@ void client.getLatestDeviceMetrics("unw4id41ud2p0wt", { ago: "15" });

// @ts-expect-error before must be number
void client.getMetricsByDate("unw4id41ud2p0wt", "device", "plant", "1d", "2024-01-24", { before: "1", fields: ["i_out"] });

// @ts-expect-error redirect_url must be string
void client.listOAuthMethods({ redirect_url: 123 });

// @ts-expect-error kind must be one of seqnum/relay/rsd
void client.getDeviceState("unw4id41ud2p0wt", "2024-01-24", "offline");
Loading