typescript-json-schema icon indicating copy to clipboard operation
typescript-json-schema copied to clipboard

[feature request] replace repeated nested structures with references

Open alita-moore opened this issue 2 years ago • 2 comments

Sorry if this behavior already exists, I've reviewed the docs and am pretty sure it doesn't. I'm transferring ts -> json schema and then json schema -> pydantic. In this process the naming is important because otherwise some types will lose their meaning. Currently, it is challenging for the json schema -> pydantic model to resolve nested common refs. The --aliasRefs seems to address this issue nicely, but it does abstract the names of the refs as well. For instance,

{
       "ISectionBlockMetadata": {
            "$ref": "#/definitions/__type_2"
        },
        "__type_2": {
            "properties": {
                "asText": {
                    "title": "asText",
                    "type": "string"
                }
            },
            "required": [
                "asText"
            ],
            "title": "__type_2",
            "type": "object"
        },
}

In this case the definition of __type_2 and its name ISectionBlockMetadata are separated. When it's compiled to pydantic it converts to something like:

class ISectionBlockMetadata(BaseModel):
    __root__: FieldType2
class FieldType2(BaseModel):
    as_text: str = Field(..., alias='asText', title='asText')

I believe a possible workaround for this could be to simply pass the parent ref name to the child as the title. For instance, I'd expect it to result in the following:

{
       "ISectionBlockMetadata": {
            "$ref": "#/definitions/__type_2"
        },
        "__type_2": {
            "properties": {
                "asText": {
                    "title": "asText",
                    "type": "string"
                }
            },
            "required": [
                "asText"
            ],
            "title": "ISectionBlockMetadata",
            "type": "object"
        },
}

alita-moore avatar Jul 08 '23 19:07 alita-moore

I messed around with it and was able to get the behavior I want. To do this I have to run two javascript files, one after the other. Here's how I did it:

const fs = require('fs');

fs.readFile('./out/generated.json', 'utf8', (err, jsonString) => {
    if (err) {
        console.log("File read failed:", err);
        return;
    }

    const data = JSON.parse(jsonString);

    // Make sure there is a definitions object
    if (!data.definitions) {
        console.log("No definitions found in JSON file.");
        return;
    }

    const refCount = {};

    // First pass: count all references
    Object.values(data.definitions).forEach((definition) => {
        // Check if definition is an object
        if (typeof definition === 'object' && definition !== null && definition.hasOwnProperty('$ref')) {
            const refName = definition['$ref'].split("/").pop();
            if(refCount[refName]) {
                refCount[refName]++;
            } else {
                refCount[refName] = 1;
            }
        }
    });

    // Second pass: rename reference and delete old one if it's used only once
    Object.keys(data.definitions).forEach((key) => {
        const definition = data.definitions[key];
        // Check if definition is an object
        if (typeof definition === 'object' && definition !== null && definition.hasOwnProperty('$ref')) {
            const refName = definition['$ref'].split("/").pop();

            if(refCount[refName] === 1) {
                data.definitions[key] = data.definitions[refName];
                data.definitions[key].title = key;
                delete data.definitions[refName];
            }
        }
    });

    fs.writeFile('./out/generated.json', JSON.stringify(data, null, 2), 'utf8', function(err) {
        if(err) {
            console.log("An error occurred while writing JSON Object to file.");
            return console.log(err);
        }
        console.log("JSON file has been saved with updated titles.");
    });
});

then,

const fs = require('fs');
const _ = require('lodash');

function replaceNestedObjectsWithRefs(jsonObj) {
    const definitions = jsonObj.definitions;

    const isObjectEqual = (object1, object2) => {
        // Exclude the "title" property for comparison
        const { title: __, ...object1WithoutTitle } = object1;
        const { title: ___, ...object2WithoutTitle } = object2;

        return _.isEqual(object1WithoutTitle, object2WithoutTitle);
    };

    for (const key in definitions) {
        const def = definitions[key];
        _.forIn(def, function process(value, key, object) {
            if (_.isObject(value)) {
                for (const defKey in definitions) {
                    if (isObjectEqual(value, definitions[defKey])) {
                        object[key] = { "$ref": `#/definitions/${defKey}` };
                    } else {
                        _.forIn(value, process);
                    }
                }
            }
        });
    }

    return jsonObj;
}

fs.readFile('./out/generated.json', 'utf8', (err, data) => {
    if (err) {
        console.error('Error reading file:', err);
        return;
    }

    const jsonObj = JSON.parse(data);
    const updatedJsonObj = replaceNestedObjectsWithRefs(jsonObj);

    fs.writeFile('schema.json', JSON.stringify(updatedJsonObj, null, 2), (err) => {
        if (err) {
            console.error('Error writing file:', err);
        }
    });
});

note that this is a hacky solution, but maybe it'll be of use to aid in explaining the request?

alita-moore avatar Jul 09 '23 01:07 alita-moore

I needed to optimize the above because it was very slow, here's the updated version, I also moved it to typescript and wrote tests for it. You can see the code in this gist: https://gist.github.com/alita-moore/a2ce9b9ceefcf9fa9f14a965f76fe015

edit: I made a change to sort the keys in the objects before checking for repeats. That way it improves accuracy.

alita-moore avatar Jul 23 '23 14:07 alita-moore