import { ExpandedPkgSpec } from "../../spec/pkgs.ts";
import {
GcpDiscoveryDocument,
GcpMethod,
GcpResource,
GcpSchema,
NormalizedGcpSchema,
} from "./schema.ts";
import { JSONSchema } from "../draft_07.ts";
import { makeModule } from "../generic/index.ts";
import { gcpProviderConfig } from "./provider.ts";
import { OnlyProperties } from "../../spec/props.ts";
import { CfHandler, CfHandlerKind, CfProperty } from "../types.ts";
import logger from "../../logger.ts";
import {
buildGcpTypeName,
detectCreateOnlyProperties,
titleCaseResourcePath,
} from "./utils.ts";
export type GcpResourceMethods = {
get?: GcpMethod;
list?: GcpMethod;
insert?: GcpMethod;
update?: GcpMethod;
patch?: GcpMethod;
delete?: GcpMethod;
};
export type GcpMethodMap = Record<string, GcpMethod>;
// These shapes are the same, but this make it obvious what we are passing
// around and when
type GcpSchemaDefinition = NormalizedGcpSchema;
type RawGcpProperty = NormalizedGcpSchema;
interface ResourceSpec extends GcpResourceMethods {
resourceName: string;
resourcePath: string[];
handlers: { [key in CfHandlerKind]?: CfHandler };
}
export function parseGcpDiscoveryDocument(
doc: GcpDiscoveryDocument,
): ExpandedPkgSpec[] {
const specs: ExpandedPkgSpec[] = [];
if (!doc.resources && !doc.methods) {
logger.debug(
`No resources or methods found in ${doc.name} v${doc.version}`,
);
return specs;
}
// Collect all resources (including nested ones)
const resourceSpecs: ResourceSpec[] = [];
if (doc.resources) {
collectResources(doc.resources, [], resourceSpecs);
}
// Process each resource
for (const resourceSpec of resourceSpecs) {
try {
const spec = buildGcpResourceSpec(resourceSpec, doc);
if (spec) {
specs.push(spec);
}
} catch (e) {
logger.error(
`Failed to process resource ${resourceSpec.resourcePath.join(".")}: ${
e instanceof Error ? e.message : String(e)
}`,
);
// Continue processing other resources
}
}
logger.debug(
`Generated ${specs.length} specs from ${doc.name} v${doc.version}`,
);
return specs;
}
/// Recursively collect all resources and their methods
function collectResources(
resources: Record<string, GcpResource>,
parentPath: string[],
collected: ResourceSpec[],
) {
for (const [resourceName, resource] of Object.entries(resources)) {
const resourcePath = [...parentPath, resourceName];
if (resource.methods) {
const methods = extractMethodsFromResource(resource.methods);
// Only create a spec if there's at least a get or insert method
if (methods.get || methods.insert) {
const handlers: { [key in CfHandlerKind]?: CfHandler } = {};
const defaultHandler = { permissions: [], timeoutInMinutes: 60 };
if (methods.get) handlers.read = defaultHandler;
if (methods.list) handlers.list = defaultHandler;
if (methods.insert) {
handlers.create = defaultHandler;
}
if (methods.update || methods.patch) {
handlers.update = defaultHandler;
}
if (methods.delete) handlers.delete = defaultHandler;
collected.push({
resourceName,
resourcePath,
...methods,
handlers,
});
}
}
// Recursively process nested resources
if (resource.resources) {
collectResources(resource.resources, resourcePath, collected);
}
}
}
function buildGcpResourceSpec(
resourceSpec: ResourceSpec,
doc: GcpDiscoveryDocument,
): ExpandedPkgSpec | null {
const {
resourcePath,
get,
insert,
update,
patch,
delete: deleteMethod,
list,
handlers,
} = resourceSpec;
// We need at least a get method to build a spec
if (!get) {
logger.debug(
`No GET method found for resource ${resourcePath.join(".")}`,
);
return null;
}
// Get the response schema for resource_value properties
const getResponseSchema = get.response;
if (!getResponseSchema) {
logger.debug(
`No response schema found for GET method of ${resourcePath.join(".")}`,
);
return null;
}
const resourceValueProperties = normalizeGcpSchemaProperties(
getResponseSchema,
);
// Get the request schema for domain properties
const domainProperties = insert?.request
? normalizeGcpSchemaProperties(insert.request)
: {};
// Merge update/patch properties into domain
if (update?.request) {
const updateProps = normalizeGcpSchemaProperties(update.request);
Object.assign(domainProperties, updateProps);
}
if (patch?.request) {
const patchProps = normalizeGcpSchemaProperties(patch.request);
Object.assign(domainProperties, patchProps);
}
// Add path parameters (region, zone, etc.) to domain - these are needed to build API URLs
// Exclude "project" as it comes from the credential
// Only override if the property is readOnly or doesn't exist - some resources (like Subnetwork)
// have writable region/zone fields that should be sent in the body too
// Track which ones we add as path-only so we can mark them required later
const pathParamsToAdd = ["region", "zone", "location"];
const addedPathOnlyParams: string[] = [];
for (const paramName of pathParamsToAdd) {
if (insert?.parameters?.[paramName]) {
const existingProp = domainProperties[paramName];
const isReadOnly = existingProp && typeof existingProp === "object" && existingProp.readOnly;
if (!existingProp || isReadOnly) {
const param = insert.parameters[paramName];
domainProperties[paramName] = {
type: param.type || "string",
description: param.description,
};
// Track this as path-only if we're overriding a readOnly prop or adding new
if (param.required) {
addedPathOnlyParams.push(paramName);
}
}
}
}
// Remove read-only properties from domain
const writableDomainProperties = Object.fromEntries(
Object.entries(domainProperties).filter(([_, prop]) =>
typeof prop === "object" && prop !== null && !prop.readOnly
),
);
// Determine primary identifier from path parameters
const primaryIdentifier = determinePrimaryIdentifier(get);
const typeName = buildGcpTypeName(doc.title || doc.name, resourcePath);
// Normalize the asset description
const description = normalizeDescription(
getResponseSchema.description ||
get.description ||
`GCP ${doc.name} ${titleCaseResourcePath(resourcePath)} resource`,
)!;
// Detect required properties for creation
// GCP uses annotations.required on each method that require it
const requiredProperties = new Set<string>();
// First check schema-level required array (rarely populated in GCP)
for (const prop of getResponseSchema.required || insert?.request?.required || []) {
requiredProperties.add(prop);
}
// Then check property-level annotations.required for the insert method
if (insert?.request?.properties) {
const insertMethodId = insert.id; // e.g., "compute.instances.insert"
for (const [propName, propDef] of Object.entries(insert.request.properties)) {
if (propDef.annotations?.required?.includes(insertMethodId)) {
requiredProperties.add(propName);
}
}
}
// Mark path-only parameters as required (e.g., region for Address, zone for Instance)
for (const paramName of addedPathOnlyParams) {
requiredProperties.add(paramName);
}
const schema: GcpSchema = {
typeName,
description,
requiredProperties,
handlers,
service: doc.name,
title: doc.title || doc.name,
version: doc.version,
resourcePath,
baseUrl: doc.baseUrl,
documentationLink: doc.documentationLink,
methods: {
get,
insert,
update,
patch,
delete: deleteMethod,
list,
},
};
const onlyProperties: OnlyProperties = {
createOnly: [],
readOnly: [],
writeOnly: [],
primaryIdentifier,
};
// Classify properties
const writeableProps = new Set(Object.keys(writableDomainProperties));
const getProps = new Set(Object.keys(resourceValueProperties));
// createOnly: Detect properties marked as immutable or creation-time-only
onlyProperties.createOnly = detectCreateOnlyProperties(insert?.request);
// readOnly: in GET but not writable (INSERT/UPDATE/PATCH)
for (const prop of getProps) {
if (!writeableProps.has(prop)) {
onlyProperties.readOnly.push(prop);
}
}
// writeOnly: writable (INSERT/UPDATE/PATCH) but not in GET
for (const prop of writeableProps) {
if (!getProps.has(prop)) {
onlyProperties.writeOnly.push(prop);
}
}
return makeModule(
schema,
description,
onlyProperties,
gcpProviderConfig,
writableDomainProperties as Record<string, CfProperty>,
resourceValueProperties as Record<string, CfProperty>,
);
}
function normalizeGcpSchemaProperties(
schema: GcpSchemaDefinition,
): Record<string, JSONSchema> {
if (!schema.properties) {
return {};
}
const normalized: Record<string, JSONSchema> = {};
for (const [propName, propDef] of Object.entries(schema.properties)) {
normalized[propName] = normalizeGcpProperty(propDef);
}
return normalized;
}
/**
* Normalizes description text by replacing newlines with spaces
* and collapsing multiple spaces. GCP descriptions often have
* embedded newlines that cause awkward formatting.
*/
function normalizeDescription(desc: string | undefined): string | undefined {
if (!desc) return desc;
return desc
.replace(/\n/g, " ") // Replace newlines with spaces
.replace(/\s+/g, " ") // Collapse multiple spaces
.trim();
}
export function normalizeGcpProperty(
prop: RawGcpProperty,
): NormalizedGcpSchema {
const normalized: NormalizedGcpSchema = { ...prop };
// Normalize the prop descriptions
if (normalized.description) {
normalized.description = normalizeDescription(normalized.description);
}
// Transform "any" type to "string" (most permissive type that's supported)
if (normalized.type === "any") {
normalized.type = "string";
}
// Note: GCP uses string type with int32/int64/uint32/uint64 format to avoid JS precision issues
// We keep these as strings since the API returns them as strings, not actual integers
// Converting to integer would cause type mismatches when SI tries to populate values
// Normalize formats to SI-supported ones
if (normalized.format) {
const format = normalized.format;
if (
normalized.type === "integer" &&
(
format === "int32" ||
format === "int64" ||
format === "uint32" ||
format === "uint64"
)
) {
delete normalized.format;
} else if (
normalized.type === "number" &&
(
format === "float" ||
format === "double" ||
format === "decimal"
)
) {
normalized.format = "double";
} else if (
normalized.type === "string" &&
(
format === "date-time" ||
format === "date-time-rfc1123" ||
format === "google-datetime"
)
) {
normalized.format = "date-time";
} else if (
normalized.type === "string" &&
(format === "uri" || format === "url")
) {
normalized.format = "uri";
} else if (
[
"uuid",
"email",
"duration",
"google-duration",
"google-fieldmask",
"date",
"time",
"byte",
"binary",
"password",
"int32",
"int64",
"uint32",
"uint64",
].includes(format)
) {
delete normalized.format;
}
}
// Parse minimum/maximum from strings to numbers
if (
normalized.minimum !== undefined && typeof normalized.minimum === "string"
) {
normalized.minimum = parseFloat(normalized.minimum);
}
if (
normalized.maximum !== undefined && typeof normalized.maximum === "string"
) {
normalized.maximum = parseFloat(normalized.maximum);
}
// Recursively normalize nested structures
if (prop.properties) {
normalized.properties = {};
for (const [key, value] of Object.entries(prop.properties)) {
normalized.properties[key] = normalizeGcpProperty(value);
}
}
if (prop.items) {
normalized.items = normalizeGcpProperty(prop.items);
}
if (prop.additionalProperties) {
normalized.additionalProperties = normalizeGcpProperty(
prop.additionalProperties,
);
}
return normalized;
}
export function extractMethodsFromResource(
methods: GcpMethodMap,
): GcpResourceMethods {
const result: GcpResourceMethods = {};
for (const [methodName, method] of Object.entries(methods)) {
const lowerName = methodName.toLowerCase();
// Map method names to CRUD operations
switch (lowerName) {
case "get":
result.get = method;
break;
case "list":
case "aggregatedlist":
case "listall":
result.list = method;
break;
case "insert":
case "create":
result.insert = method;
break;
case "update":
result.update = method;
break;
case "patch":
result.patch = method;
break;
case "delete":
result.delete = method;
break;
}
}
return result;
}
function determinePrimaryIdentifier(method: GcpMethod): string[] {
// GCP resources typically have a 'name' or 'id' field
// Look at the last parameter in parameterOrder
if (method.parameterOrder && method.parameterOrder.length > 0) {
const lastParam = method.parameterOrder[method.parameterOrder.length - 1];
// Common GCP identifier patterns
const identifierMap: Record<string, string> = {
"name": "name",
"resourceId": "id",
"id": "id",
"instanceId": "id",
"diskId": "id",
"networkId": "id",
};
return [identifierMap[lastParam] || "name"];
}
// Default to 'name' (most GCP resources use name)
return ["name"];
}