Skip to content
Merged
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
176 changes: 175 additions & 1 deletion sdk-resources/build-versioned-sdk.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand All @@ -227,6 +230,165 @@ function bundlePartition(partitionName, tempApisDir) {
};
}

// ---------------------------------------------------------------------------
// Model-name casing normalization
//
// redocly bundles each file-$ref'd schema into components/schemas/<filename>,
// 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 -<n> to de-duplicate colliding bundled names; keep that
// suffix (as _<n>) 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/<old> 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)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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);
Expand Down
30 changes: 15 additions & 15 deletions validation_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
Loading