From d8887a9bfe4260b024298c694ed25230968f2cab Mon Sep 17 00:00:00 2001 From: philip-ellis-sp Date: Thu, 16 Jul 2026 16:06:00 -0400 Subject: [PATCH 1/2] updated script to fix case names --- sdk-resources/build-versioned-sdk.js | 176 ++++++++++++++++++++++++++- 1 file changed, 175 insertions(+), 1 deletion(-) diff --git a/sdk-resources/build-versioned-sdk.js b/sdk-resources/build-versioned-sdk.js index efd6c85df..69429c140 100644 --- a/sdk-resources/build-versioned-sdk.js +++ b/sdk-resources/build-versioned-sdk.js @@ -209,7 +209,10 @@ function applyPrescriptFixes(tempApisDir) { function bundlePartition(partitionName, tempApisDir) { const inputSpec = path.join(tempApisDir, partitionName, "openapi.yaml"); - const outputSpec = path.join(BUNDLED_DIR, `${partitionName}.yaml`); + // Bundle to JSON so the casing-normalization step below can parse and rewrite + // it with plain JSON.parse/stringify (no YAML dependency). openapi-generator + // accepts JSON and YAML specs interchangeably. + const outputSpec = path.join(BUNDLED_DIR, `${partitionName}.json`); fs.mkdirSync(BUNDLED_DIR, { recursive: true }); @@ -227,6 +230,165 @@ function bundlePartition(partitionName, tempApisDir) { }; } +// --------------------------------------------------------------------------- +// Model-name casing normalization +// +// redocly bundles each file-$ref'd schema into components/schemas/, +// using the on-disk filename verbatim as the key. In the apis/ partition layout +// every schema filename is lowercase (accessduration.yaml), so openapi-generator +// only uppercases the first letter and emits `Accessduration` instead of the +// intended `AccessDuration`. We fix this in the bundled spec, before generation, +// by renaming each lowercase component-schema key to a properly-cased PascalCase +// name and rewriting every #/components/schemas/... $ref to match. +// +// The correct casing (i.e. the lost word boundaries) is recovered, in priority +// order, from: +// 1. the PascalCase filename of the same schema in the versioned spec dirs +// (idn/v3, v2024, v2025, v2026, beta) — these carry proper CamelCase names +// 2. the schema's `title` field (Title Case words → PascalCase) +// 3. first-letter capitalization (openapi-generator's default) as a last resort +// --------------------------------------------------------------------------- + +let _versionedNameMap = null; + +// Given two casings of the same (lowercased) name, pick the one that reads as a +// class name: uppercase-first wins, then the one with more uppercase letters +// (favours PascalCase words / acronyms), then lexical order for stability. +function betterCasedName(a, b) { + const au = /^[A-Z]/.test(a), bu = /^[A-Z]/.test(b); + if (au !== bu) return au ? a : b; + const ac = (a.match(/[A-Z]/g) || []).length; + const bc = (b.match(/[A-Z]/g) || []).length; + if (ac !== bc) return ac > bc ? a : b; + return a <= b ? a : b; +} + +// Scan the versioned (non-apis) spec dirs once and map each lowercased schema +// basename to its best PascalCase spelling. Cached across partitions. +function buildVersionedNameMap(idnRoot) { + if (_versionedNameMap) return _versionedNameMap; + const map = new Map(); // lowercased basename -> best PascalCase basename + + const versionDirs = fs.existsSync(idnRoot) + ? fs.readdirSync(idnRoot, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name !== "apis") + .map(e => path.join(idnRoot, e.name)) + : []; + + for (const dir of versionDirs) { + for (const file of walkSync(dir)) { + if (!file.endsWith(".yaml")) continue; + // Only files under a schemas/ dir carry model names; skip openapi.yaml, + // path files, etc. + if (!file.split(path.sep).includes("schemas")) continue; + const basename = path.basename(file, ".yaml"); + const lc = basename.toLowerCase(); + const cur = map.get(lc); + map.set(lc, cur ? betterCasedName(cur, basename) : basename); + } + } + + _versionedNameMap = map; + return map; +} + +function pascalFromTitle(title) { + return title + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(""); +} + +// Compute the desired PascalCase model name for a lowercase component key. +function desiredModelName(key, schema, nameMap) { + // redocly appends - to de-duplicate colliding bundled names; keep that + // suffix (as _) so distinct schemas stay distinct, but resolve the casing + // on the base name. + const suffixMatch = key.match(/^(.*?)-(\d+)$/); + const base = suffixMatch ? suffixMatch[1] : key; + const suffix = suffixMatch ? `_${suffixMatch[2]}` : ""; + + let name = + nameMap.get(base.toLowerCase()) || + (schema && typeof schema.title === "string" && schema.title.trim() + ? pascalFromTitle(schema.title) + : ""); + + if (!name) name = base; // last resort — leave word boundaries as-is + + // Strip anything that can't appear in a class name (the generator sanitizes + // too, but doing it here keeps the uniqueness check below accurate). + name = name.replace(/[^A-Za-z0-9]/g, ""); + if (!name) name = base.replace(/[^A-Za-z0-9]/g, "") || "Model"; + name = name.charAt(0).toUpperCase() + name.slice(1); + return name + suffix; +} + +// Rename lowercase component-schema keys to PascalCase and rewrite all $refs. +// Returns { renamed } — the number of schema keys that were changed. +function normalizeSchemaNames(bundledJsonPath, idnRoot) { + const spec = JSON.parse(fs.readFileSync(bundledJsonPath, "utf8")); + const schemas = spec.components && spec.components.schemas; + if (!schemas) return { renamed: 0 }; + + const nameMap = buildVersionedNameMap(idnRoot); + const oldKeys = Object.keys(schemas); + + // A key needs fixing only if it is filename-derived (all lowercase). Keys that + // already contain an uppercase letter are intentional inline names — leave them + // and reserve them so we never rename onto one. + const needsFix = k => !/[A-Z]/.test(k); + const taken = new Set(oldKeys.filter(k => !needsFix(k))); + + const rename = new Map(); // old key -> new key + for (const key of oldKeys) { + if (!needsFix(key)) continue; + let name = desiredModelName(key, schemas[key], nameMap); + if (name === key) continue; // already correct (e.g. single-word "bound") + if (taken.has(name)) { + let n = 2, candidate = `${name}_${n}`; + while (taken.has(candidate)) candidate = `${name}_${++n}`; + console.log(` name collision: ${key} -> ${name} taken, using ${candidate}`); + name = candidate; + } + taken.add(name); + rename.set(key, name); + } + + if (rename.size === 0) return { renamed: 0 }; + + // Rewrite every #/components/schemas/ reference anywhere in the tree + // (covers $ref values and discriminator.mapping values). Exact-string match + // avoids prefix collisions (e.g. accessprofile vs accessprofilebulkdelete). + const refRewrite = new Map(); + for (const [oldKey, newKey] of rename) { + refRewrite.set(`#/components/schemas/${oldKey}`, `#/components/schemas/${newKey}`); + } + const walk = (node) => { + if (Array.isArray(node)) { node.forEach(walk); return; } + if (node && typeof node === "object") { + for (const k of Object.keys(node)) { + const v = node[k]; + if (typeof v === "string") { + if (refRewrite.has(v)) node[k] = refRewrite.get(v); + } else { + walk(v); + } + } + } + }; + walk(spec); + + // Rebuild components.schemas with renamed keys, preserving original order. + const rebuilt = {}; + for (const key of oldKeys) rebuilt[rename.get(key) || key] = schemas[key]; + spec.components.schemas = rebuilt; + + fs.writeFileSync(bundledJsonPath, JSON.stringify(spec, null, 2), "utf8"); + return { renamed: rename.size }; +} + // --------------------------------------------------------------------------- // Generate per-partition config YAML (Python-specific) // --------------------------------------------------------------------------- @@ -864,6 +1026,18 @@ function main() { continue; } + // --- Step 1b: Normalize model-name casing in the bundled spec --- + console.log(" [1b/5] Normalizing model-name casing ..."); + try { + const norm = normalizeSchemaNames(bundle.outputSpec, path.dirname(apisDir)); + console.log(` renamed ${norm.renamed} lowercase model name(s)`); + } catch (err) { + console.error(` ✗ casing normalization failed`); + const reportPath = writeErrorReport(partition, "normalization", String(err.stack || err), TEMP_DIR, apisDir); + results.failed.push({ partition, step: "normalization", reportPath }); + continue; + } + // --- Step 2: Config --- console.log(" [2/5] Writing generator config ..."); const configPath = writePartitionConfig(partition); From 05e7f0aeb616b4429c137e6ee88a12281d933fe9 Mon Sep 17 00:00:00 2001 From: philip-ellis-sp Date: Thu, 16 Jul 2026 17:14:24 -0400 Subject: [PATCH 2/2] updated test --- validation_test.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/validation_test.py b/validation_test.py index 4f0517f6d..569c1e0b5 100644 --- a/validation_test.py +++ b/validation_test.py @@ -215,52 +215,52 @@ def setUpClass(cls): def test_requested_item_status_accepts_known_state_enum(self): """Known state value is coerced to the RequestedItemStatusRequestState enum member.""" - from sailpoint.access_requests.models.requesteditemstatus import Requesteditemstatus - from sailpoint.access_requests.models.requesteditemstatusrequeststate import Requesteditemstatusrequeststate + from sailpoint.access_requests.models.requested_item_status import RequestedItemStatus + from sailpoint.access_requests.models.requested_item_status_request_state import RequestedItemStatusRequestState - obj = Requesteditemstatus.model_validate({"state": "EXECUTING"}) + obj = RequestedItemStatus.model_validate({"state": "EXECUTING"}) self.assertIsNotNone(obj.state) - self.assertEqual(obj.state, Requesteditemstatusrequeststate.EXECUTING) + self.assertEqual(obj.state, RequestedItemStatusRequestState.EXECUTING) def test_requested_item_status_accepts_unknown_state_as_str(self): """Unknown state value is accepted as plain str (lenient enum).""" - from sailpoint.access_requests.models.requesteditemstatus import Requesteditemstatus + from sailpoint.access_requests.models.requested_item_status import RequestedItemStatus unknown_state = "FUTURE_STATE_NOT_IN_SPEC" - obj = Requesteditemstatus.model_validate({"state": unknown_state}) + obj = RequestedItemStatus.model_validate({"state": unknown_state}) self.assertIsNotNone(obj.state) self.assertEqual(obj.state, unknown_state) self.assertIsInstance(obj.state, str) def test_requested_item_status_accepts_known_request_type_enum(self): """Known request_type value is coerced to AccessRequestType enum member.""" - from sailpoint.access_requests.models.requesteditemstatus import Requesteditemstatus - from sailpoint.access_requests.models.accessrequesttype import Accessrequesttype + from sailpoint.access_requests.models.requested_item_status import RequestedItemStatus + from sailpoint.access_requests.models.access_request_type import AccessRequestType - obj = Requesteditemstatus.model_validate({"requestType": "GRANT_ACCESS"}) + obj = RequestedItemStatus.model_validate({"requestType": "GRANT_ACCESS"}) self.assertIsNotNone(obj.request_type) - self.assertEqual(obj.request_type, Accessrequesttype.GRANT_ACCESS) + self.assertEqual(obj.request_type, AccessRequestType.GRANT_ACCESS) def test_requested_item_status_accepts_unknown_request_type_as_str(self): """Unknown request_type value is accepted as plain str (lenient enum).""" - from sailpoint.access_requests.models.requesteditemstatus import Requesteditemstatus + from sailpoint.access_requests.models.requested_item_status import RequestedItemStatus unknown_type = "FUTURE_REQUEST_TYPE" - obj = Requesteditemstatus.model_validate({"requestType": unknown_type}) + obj = RequestedItemStatus.model_validate({"requestType": unknown_type}) self.assertIsNotNone(obj.request_type) self.assertEqual(obj.request_type, unknown_type) self.assertIsInstance(obj.request_type, str) def test_requested_item_status_unknown_state_round_trips_in_dict(self): """Unknown state serializes and deserializes correctly (e.g. for API responses).""" - from sailpoint.access_requests.models.requesteditemstatus import Requesteditemstatus + from sailpoint.access_requests.models.requested_item_status import RequestedItemStatus unknown_state = "PENDING_NEW_BACKEND" - obj = Requesteditemstatus.model_validate({"state": unknown_state}) + obj = RequestedItemStatus.model_validate({"state": unknown_state}) d = obj.model_dump(by_alias=True) self.assertIn("state", d) self.assertEqual(d["state"], unknown_state) - obj2 = Requesteditemstatus.model_validate(d) + obj2 = RequestedItemStatus.model_validate(d) self.assertEqual(obj2.state, unknown_state)