diff --git a/clients/typescript/README.md b/clients/typescript/README.md index c50c363..fa7874b 100644 --- a/clients/typescript/README.md +++ b/clients/typescript/README.md @@ -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`), diff --git a/clients/typescript/src/client.ts b/clients/typescript/src/client.ts index 4e6ae5b..da41a58 100644 --- a/clients/typescript/src/client.ts +++ b/clients/typescript/src/client.ts @@ -21,6 +21,56 @@ export type QueryValue = */ export type JsonObject = Record; +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 { + $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}. */ @@ -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 { + return this.request("GET", "/api/v3/account/auth-methods", { query, options }) as Promise; + } + + /** + * Starts the OAuth2 login flow and returns the provider redirect URL. + */ + async getOAuth2LoginUrl( + provider: string, + redirectUrl?: string, + options?: RequestOptions + ): Promise { + 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 + ); + 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. * @@ -464,6 +588,66 @@ export class PatchClientV3 { }); } + /** + * Lists combiner model info rows from the asset catalog. + */ + async listCombinerModelInfo( + options?: RequestOptions + ): Promise> { + return this.request("GET", "/api/v3/model-info/combiners", { options }) as Promise< + ModelInfoListBody + >; + } + + /** + * Lists inverter model info rows from the asset catalog. + */ + async listInverterModelInfo( + options?: RequestOptions + ): Promise> { + return this.request("GET", "/api/v3/model-info/inverters", { options }) as Promise< + ModelInfoListBody + >; + } + + /** + * Lists module model info rows from the asset catalog. + */ + async listModuleModelInfo(options?: RequestOptions): Promise> { + return this.request("GET", "/api/v3/model-info/modules", { options }) as Promise< + ModelInfoListBody + >; + } + + /** + * Retrieves device state indicator data. + */ + async getDeviceState( + plantId: string, + date: string, + kind: DeviceStateKind, + options?: RequestOptions + ): Promise { + 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 { + return this.request("GET", `/api/v3/plants/${encodePath(plantId)}/registry/stat`, { + query: { date }, + options, + }) as Promise; + } + /** * Lists inverter logs for a plant. * @@ -615,24 +799,7 @@ export class PatchClientV3 { } private async request(method: string, path: string, input: RequestInput = {}): Promise { - 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" }, @@ -747,6 +914,27 @@ export class PatchClientV3 { return headers; } + + private buildUrl(path: string, query?: Record): 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 { diff --git a/clients/typescript/src/index.ts b/clients/typescript/src/index.ts index d9c4a6d..25bcf21 100644 --- a/clients/typescript/src/index.ts +++ b/clients/typescript/src/index.ts @@ -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"; diff --git a/clients/typescript/test-types/client-signatures.test.ts b/clients/typescript/test-types/client-signatures.test.ts index bd62281..fd8daae 100644 --- a/clients/typescript/test-types/client-signatures.test.ts +++ b/clients/typescript/test-types/client-signatures.test.ts @@ -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" }); @@ -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"); diff --git a/clients/typescript/test/client-error-parsing.test.js b/clients/typescript/test/client-error-parsing.test.js index 8119760..2882766 100644 --- a/clients/typescript/test/client-error-parsing.test.js +++ b/clients/typescript/test/client-error-parsing.test.js @@ -523,3 +523,115 @@ test("clears timeout timer when fetchFn throws synchronously", async () => { }); assert.ok(Date.now() - started < 500, "synchronous failure should not wait for timeout"); }); + +test("listOAuthMethods serializes provider and redirect_url query parameters", async () => { + let observedUrl = ""; + const client = new PatchClientV3({ + fetchFn: async (url) => { + observedUrl = url; + return new Response(JSON.stringify({ authProviders: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + const out = await client.listOAuthMethods({ + provider: "google", + redirect_url: "myscheme://callback", + }); + assert.deepEqual(out, { authProviders: [] }); + assert.match( + observedUrl, + /\/api\/v3\/account\/auth-methods\?provider=google&redirect_url=myscheme%3A%2F%2Fcallback$/ + ); +}); + +test("getOAuth2LoginUrl uses manual redirect mode and returns Location header", async () => { + let observedRedirect = ""; + let observedUrl = ""; + let observedAuthHeader = ""; + const client = new PatchClientV3({ + accessToken: "token-value", + accountType: "manager", + fetchFn: async (url, init) => { + observedUrl = url; + observedRedirect = init?.redirect ?? ""; + observedAuthHeader = new Headers(init?.headers).get("authorization") ?? ""; + return new Response(null, { + status: 302, + headers: { location: "https://accounts.example.com/oauth?state=abc" }, + }); + }, + }); + + const location = await client.getOAuth2LoginUrl("google", "myscheme://callback"); + assert.equal(location, "https://accounts.example.com/oauth?state=abc"); + assert.equal(observedRedirect, "manual"); + assert.equal(observedAuthHeader, "Bearer token-value"); + assert.match( + observedUrl, + /\/api\/v3\/account\/login-with-oauth2\?provider=google&redirect_url=myscheme%3A%2F%2Fcallback$/ + ); +}); + +test("listCombinerModelInfo requests the combiner catalog endpoint", async () => { + let observedUrl = ""; + const client = new PatchClientV3({ + accessToken: "token-value", + accountType: "manager", + fetchFn: async (url) => { + observedUrl = url; + return new Response(JSON.stringify({ items: [{ id: "comb-1" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + const out = await client.listCombinerModelInfo(); + assert.deepEqual(out, { items: [{ id: "comb-1" }] }); + assert.match(observedUrl, /\/api\/v3\/model-info\/combiners$/); +}); + +test("getDeviceState serializes date and kind query parameters", async () => { + let observedUrl = ""; + const client = new PatchClientV3({ + accessToken: "token-value", + accountType: "manager", + fetchFn: async (url) => { + observedUrl = url; + return new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + await client.getDeviceState("plant-1", "2024-01-24", "relay"); + assert.match( + observedUrl, + /\/api\/v3\/plants\/plant-1\/indicator\/device-state\?date=2024-01-24&kind=relay$/ + ); +}); + +test("getPlantRegistryStat builds the stat endpoint query", async () => { + let observedUrl = ""; + const client = new PatchClientV3({ + accessToken: "token-value", + accountType: "manager", + fetchFn: async (url) => { + observedUrl = url; + return new Response(JSON.stringify({ timestamp: "2024-01-24T15:00:00Z" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + await client.getPlantRegistryStat("plant-1", "2024-01-24"); + assert.match( + observedUrl, + /\/api\/v3\/plants\/plant-1\/registry\/stat\?date=2024-01-24$/ + ); +});