diff --git a/.coverage b/.coverage index a65443a..1dd0da0 100644 Binary files a/.coverage and b/.coverage differ diff --git a/README.md b/README.md index 115c738..711a3a0 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ JSON Schema (for) Network as Code The majority of Network and Infrastructure automation is done in YAML. Be it Ansible Host Variables, Network Device data to build a Jinja2 configuration with, or just a collection of data you want to run a script against to put into another product you have likely written a YAML file and have had to debug a typo or had to help another colleague build a new file for a new device. -In an ideal world you can (and should) put a lot of this data into a database or Network Source of Truth solution and pull from it so the validation is done for you. However, these solutions don't cover every case and generally don't play nice with a GIT CI/CD process so you still will likely end up creating some YAML files here and there. +In an ideal world you can (and should) put a lot of this data into a database or Network Source of Truth solution and pull from it so the validation is done for you. However, these solutions don't cover every use case so you will likely end up creating some YAML files here and there. -Using a JSON schema for validating your YAML is a good practice in a CI/CD world but is very cumbersome to create from scratch. +Using a JSON schema for validating & documenting your YAML is a good practice in a CI/CD world but is very cumbersome to create from scratch. -This project aims to simplify this whole process to help you build a decent JSON schema base from a YAML file with network and infrastructure data definitions in mind. +This project aims to simplify this whole process by helping you build a JSON schema using YAML syntax that has network and infrastructure templates (or sub-schemas) in mind. Now you can hopefully catch those rare mistakes before you run that Playbook, create that configuration with a Jinja2 template or run a REST query to that Source of Truth or Telemetry solution :) @@ -24,7 +24,7 @@ Take a basic Ansible host_vars YAML file for a host below: chassis: hostname: "ceos-spine1" model: "ceos" - type: "router" + device_type: "router" system: domain_name: "example.com" @@ -40,55 +40,128 @@ interfaces: ipv4: "10.1.0.20/24" ``` -You can simply write out how you would like to validate this data, and this program will write out a JSON schema you can use. You can just also keep your existing data if you just want some basic type validation (string, integer, float, array, etc.). +You can simply write out how you would like to document & validate this data in YAML using kinds, and this program will write out a JSON schema you can use. ```yaml -chassis: - hostname: - jsnac_type: pattern - jsnac_pattern: "^ceos-[a-zA-Z]{1,16}[0-9]$" - model: "ceos" - type: - jsnac_type: choice - jsnac_choices: ["router", "switch", "spine", "leaf"] - -system: - domain_name: - jsnac_type: domain - ntp_servers: - jsnac_type: ipv4 - -interfaces: - - if: - jsnac_type: string - desc: - jsnac_type: string - ipv4: - jsnac_type: ipv4_cidr - ipv6: - jsnac_type: ipv6_cidr +header: + title: "Ansible host vars" + +schema: + chassis: + title: "Chassis" + type: "object" + properties: + hostname: + kind: { name: "string" } + model: + kind: { name: "string" } + device_type: + kind: { name: "choice", choices: [ "router", "switch", "firewall", "load-balancer" ] } + system: + type: "object" + properties: + domain_name: + kind: { name: "string" } + ntp_servers: + type: "array" + items: + kind: { name: "ipv4" } + interfaces: + type: "array" + items: + type: "object" + properties: + if: + kind: { name: "string" } + desc: + kind: { name: "string" } + ipv4: + kind: { name: "ipv4_cidr" } + ipv6: + kind: { name: "ipv6_cidr" } ``` -Alternatively, you can also just use dictionaries inplace: +We also have full support for writing your own titles, descriptions, kinds (sub-schemas), objects that are required, etc. A more fleshed out example of the same schema is below: ```yaml -chassis: - hostname: { jsnac_type: pattern, jsnac_pattern: "^ceos-[a-zA-Z]{1,16}[0-9]$" } - model: "ceos" - type: { jsnac_type: choice, jsnac_choices: ["router", "switch", "spine", "leaf"] } - -system: - domain_name: { jsnac_type: domain } - ntp_servers: { jsnac_type: ipv4 } - -interfaces: - - if: { jsnac_type: string } - desc: { jsnac_type: string } - ipv4: { jsnac_type: ipv4_cidr } - ipv6: { jsnac_type: ipv6_cidr } +header: + id: "example-schema.json" + title: "Ansible host vars" + description: | + Ansible host vars for my networking device. Requires the below objects: + - chassis + - system + - interfaces + +kinds: + hostname: + title: "Hostname" + description: "Hostname of the device" + type: "pattern" + regex: "^[a-zA-Z0-9-]{1,63}$" + +schema: + chassis: + title: "Chassis" + description: | + Object containing Chassis information. Has the below properties: + hostname [required]: hostname + model [required]: string + device_type [required]: choice (router, switch, firewall, load-balancer) + type: "object" + properties: + hostname: + kind: { name: "hostname" } + model: + kind: { name: "string" } + device_type: + title: "Device Type" + description: | + Device Type options are: + router, switch, firewall, load-balancer + kind: { name: "choice", choices: [ "router", "switch", "firewall", "load-balancer" ] } + required: [ "hostname", "model", "device_type" ] + system: + title: "System" + description: | + Object containing System information. Has the below properties: + domain_name [required]: string + ntp_servers [required]: list of ipv4 addresses + type: "object" + properties: + domain_name: + kind: { name: "string" } + ntp_servers: + title: "NTP Servers" + description: "List of NTP servers" + type: "array" + items: + kind: { name: "ipv4" } + required: [ "domain_name", "ntp_servers" ] + interfaces: + title: "Device Interfaces" + description: | + List of device interfaces. Each interface has the below properties: + if [required]: string + desc: string + ipv4: ipv4_cidr + ipv6: ipv6_cidr + type: "array" + items: + type: "object" + properties: + if: + kind: { name: "string" } + desc: + kind: { name: "string" } + ipv4: + kind: { name: "ipv4_cidr" } + ipv6: + kind: { name: "ipv6_cidr" } + required: [ "if" ] ``` -A full list of jsnac_types is available in the documentation (readthedocs coming soon) +A full list of kinds are available in the ![documentation](https://jsnac.readthedocs.io/en/latest/) ## Usage @@ -104,7 +177,7 @@ jsnac -f data/example-jsnac.yml # Build a JSON schema from a YAML file and save it to a custom file jsnac -f data/example-jsnac.yml -o my.schema.json -# Increase the verbosity of the output +# Increase the verbosity of the output (this generates alot of messages as I use it for debugging) jsnac -f data/example-jsnac.yml -v ``` @@ -129,7 +202,7 @@ def main(): # jsnac.add_json(json_data) # Build the JSON schema - schema = jsnac.build() + schema = jsnac.build_schema() print(schema) if __name__ == '__main__': diff --git a/data/example-jsnac.json b/data/example-jsnac.json index c1f0e9d..eafccbb 100644 --- a/data/example-jsnac.json +++ b/data/example-jsnac.json @@ -1,44 +1,112 @@ { - "chassis": { + "header": { + "id": "example-schema.json", + "schema": "http://json-schema.org/draft-07/schema", + "title": "Example Schema", + "description": "Ansible host vars for my networking device. Requires the below objects:\n- chassis\n- system\n- interfaces\n" + }, + "kinds": { "hostname": { - "jsnac_type": "pattern", - "jsnac_pattern": "^ceos-[a-zA-Z]{1,16}[0-9]$" - }, - "model": "ceos", - "type": { - "jsnac_type": "choice", - "jsnac_choices": [ - "router", - "switch", - "spine", - "leaf" - ] + "title": "Hostname", + "description": "Hostname of the device", + "type": "pattern", + "regex": "^[a-zA-Z0-9-]{1,63}$" } }, - "system": { - "domain_name": { - "jsnac_type": "domain" - }, - "ntp_servers": [ - { - "jsnac_type": "ipv4" - } - ] - }, - "interfaces": [ - { - "if": { - "jsnac_type": "string" + "schema": { + "chassis": { + "title": "Chassis", + "description": "Object containing Chassis information. Has the below properties: \nhostname [required]: hostname\nmodel [required]: string\ndevice_type [required]: choice (router, switch, firewall, load-balancer)\n", + "type": "object", + "properties": { + "hostname": { + "kind": { + "name": "hostname" + } + }, + "model": { + "kind": { + "name": "string" + } + }, + "device_type": { + "title": "Device Type", + "description": "Device Type options are:\nrouter, switch, firewall, load-balancer\n", + "kind": { + "name": "choice", + "choices": [ + "router", + "switch", + "firewall", + "load-balancer" + ] + } + } }, - "desc": { - "jsnac_type": "string" - }, - "ipv4": { - "jsnac_type": "ipv4_cidr" + "required": [ + "hostname", + "model", + "device_type" + ] + }, + "system": { + "title": "System", + "description": "Object containing System information. Has the below properties:\ndomain_name [required]: string\nntp_servers [required]: list of ipv4 addresses\n", + "type": "object", + "properties": { + "domain_name": { + "kind": { + "name": "string" + } + }, + "ntp_servers": { + "title": "NTP Servers", + "description": "List of NTP servers", + "type": "array", + "items": { + "kind": { + "name": "ipv4" + } + } + } }, - "ipv6": { - "jsnac_type": "ipv6_cidr" + "required": [ + "domain_name", + "ntp_servers" + ] + }, + "interfaces": { + "title": "Device Interfaces", + "description": "List of device interfaces. Each interface has the below properties:\nif [required]: string\ndesc: string\nipv4: ipv4_cidr\nipv6: ipv6_cidr\n", + "type": "array", + "items": { + "type": "object", + "properties": { + "if": { + "kind": { + "name": "string" + } + }, + "desc": { + "kind": { + "name": "string" + } + }, + "ipv4": { + "kind": { + "name": "ipv4_cidr" + } + }, + "ipv6": { + "kind": { + "name": "ipv6_cidr" + } + } + }, + "required": [ + "if" + ] } } - ] + } } \ No newline at end of file diff --git a/data/example-jsnac.yml b/data/example-jsnac.yml index b29215d..dd13cf0 100644 --- a/data/example-jsnac.yml +++ b/data/example-jsnac.yml @@ -1,14 +1,77 @@ -chassis: - hostname: { jsnac_type: pattern, jsnac_pattern: "^ceos-[a-zA-Z]{1,16}[0-9]$" } - model: "ceos" - type: { jsnac_type: choice, jsnac_choices: ["router", "switch", "spine", "leaf"] } +--- +header: + id: "example-schema.json" + schema: "http://json-schema.org/draft-07/schema" + title: "Example Schema" + description: | + Ansible host vars for my networking device. Requires the below objects: + - chassis + - system + - interfaces -system: - domain_name: { jsnac_type: domain } - ntp_servers: [ { jsnac_type: ipv4 } ] - -interfaces: - - if: { jsnac_type: string } - desc: { jsnac_type: string } - ipv4: { jsnac_type: ipv4_cidr } - ipv6: { jsnac_type: ipv6_cidr } \ No newline at end of file +kinds: + hostname: + title: "Hostname" + description: "Hostname of the device" + type: "pattern" + regex: "^[a-zA-Z0-9-]{1,63}$" + +schema: + chassis: + title: "Chassis" + description: | + Object containing Chassis information. Has the below properties: + hostname [required]: hostname + model [required]: string + device_type [required]: choice (router, switch, firewall, load-balancer) + type: "object" + properties: + hostname: + kind: { name: "hostname" } + model: + kind: { name: "string" } + device_type: + title: "Device Type" + description: | + Device Type options are: + router, switch, firewall, load-balancer + kind: { name: "choice", choices: [ "router", "switch", "firewall", "load-balancer" ] } + required: [ "hostname", "model", "device_type" ] + system: + title: "System" + description: | + Object containing System information. Has the below properties: + domain_name [required]: string + ntp_servers [required]: list of ipv4 addresses + type: "object" + properties: + domain_name: + kind: { name: "string" } + ntp_servers: + title: "NTP Servers" + description: "List of NTP servers" + type: "array" + items: + kind: { name: "ipv4" } + required: [ "domain_name", "ntp_servers" ] + interfaces: + title: "Device Interfaces" + description: | + List of device interfaces. Each interface has the below properties: + if [required]: string + desc: string + ipv4: ipv4_cidr + ipv6: ipv6_cidr + type: "array" + items: + type: "object" + properties: + if: + kind: { name: "string" } + desc: + kind: { name: "string" } + ipv4: + kind: { name: "ipv4_cidr" } + ipv6: + kind: { name: "ipv6_cidr" } + required: [ "if" ] \ No newline at end of file diff --git a/data/example.json b/data/example.json index 888c966..0c4d37d 100644 --- a/data/example.json +++ b/data/example.json @@ -2,7 +2,7 @@ "chassis": { "hostname": "ceos-spine1", "model": "ceos", - "type": "router" + "device_type": "router" }, "system": { "domain_name": "example.com", diff --git a/data/example.schema.json b/data/example.schema.json index 3076164..b341ba1 100644 --- a/data/example.schema.json +++ b/data/example.schema.json @@ -1,7 +1,8 @@ { "$schema": "http://json-schema.org/draft-07/schema", - "title": "JSNAC Created Schema", - "description": "The below schema was created by JSNAC (https://github.com/commitconfirmed/jsnac)", + "title": "Example Schema", + "$id": "example-schema.json", + "description": "Ansible host vars for my networking device. Requires the below objects:\n- chassis\n- system\n- interfaces\n", "$defs": { "ipv4": { "type": "string", @@ -45,64 +46,86 @@ "title": "Domain Name", "description": "Domain name (String) \n Format: example.com" }, - "string": { + "hostname": { + "title": "Hostname", + "description": "Hostname of the device", "type": "string", - "pattern": "^[a-zA-Z0-9!@#$%^&*()_+-\\{\\}|:;\"'<>,.?/ ]{1,255}$", - "title": "String", - "description": "Alphanumeric string with special characters (String) \n Max length: 255" + "pattern": "^[a-zA-Z0-9-]{1,63}$" } }, "type": "object", "additionalProperties": false, "properties": { "chassis": { + "title": "Chassis", + "description": "Object containing Chassis information. Has the below properties: \nhostname [required]: hostname\nmodel [required]: string\ndevice_type [required]: choice (router, switch, firewall, load-balancer)\n", "type": "object", "properties": { "hostname": { - "type": "string", - "pattern": "^ceos-[a-zA-Z]{1,16}[0-9]$", - "title": "Custom Pattern", - "description": "Custom Pattern (regex) \n Pattern: ^ceos-[a-zA-Z]{1,16}[0-9]$" + "$ref": "#/$defs/hostname" }, "model": { - "type": "string" + "type": "string", + "title": "model", + "description": "String" }, - "type": { + "device_type": { + "title": "Device Type", + "description": "Device Type options are:\nrouter, switch, firewall, load-balancer\n", "enum": [ "router", "switch", - "spine", - "leaf" - ], - "title": "Custom Choice", - "description": "Custom Choice (enum) \n Choices: router, switch, spine, leaf" + "firewall", + "load-balancer" + ] } - } + }, + "required": [ + "hostname", + "model", + "device_type" + ] }, "system": { + "title": "System", + "description": "Object containing System information. Has the below properties:\ndomain_name [required]: string\nntp_servers [required]: list of ipv4 addresses\n", "type": "object", "properties": { "domain_name": { - "$ref": "#/$defs/domain" + "type": "string", + "title": "domain_name", + "description": "String" }, "ntp_servers": { + "title": "NTP Servers", + "description": "List of NTP servers", "type": "array", "items": { "$ref": "#/$defs/ipv4" } } - } + }, + "required": [ + "domain_name", + "ntp_servers" + ] }, "interfaces": { + "title": "Device Interfaces", + "description": "List of device interfaces. Each interface has the below properties:\nif [required]: string\ndesc: string\nipv4: ipv4_cidr\nipv6: ipv6_cidr\n", "type": "array", "items": { "type": "object", "properties": { "if": { - "$ref": "#/$defs/string" + "type": "string", + "title": "if", + "description": "String" }, "desc": { - "$ref": "#/$defs/string" + "type": "string", + "title": "desc", + "description": "String" }, "ipv4": { "$ref": "#/$defs/ipv4_cidr" @@ -110,7 +133,10 @@ "ipv6": { "$ref": "#/$defs/ipv6_cidr" } - } + }, + "required": [ + "if" + ] } } } diff --git a/data/example.yml b/data/example.yml index 8b285c1..df33864 100644 --- a/data/example.yml +++ b/data/example.yml @@ -1,9 +1,9 @@ # yaml-language-server: $schema=example.schema.json - +--- chassis: hostname: "ceos-spine1" model: "ceos" - type: "router" + device_type: "router" system: domain_name: "example.com" diff --git a/data/regenerate_test_data.py b/data/regenerate_test_data.py index 03fcc85..36a2cb9 100755 --- a/data/regenerate_test_data.py +++ b/data/regenerate_test_data.py @@ -57,7 +57,7 @@ def main() -> None: # noqa: D103 with example_jsnac_file.open() as f: jsnac = SchemaInferer() jsnac.add_yaml(f.read()) - schema = jsnac.build() + schema = jsnac.build_schema() f.close() with output_schema_file.open(mode="w") as f: f.write(schema) diff --git a/dist/jsnac-0.2.0-py3-none-any.whl b/dist/jsnac-0.2.0-py3-none-any.whl new file mode 100644 index 0000000..9812372 Binary files /dev/null and b/dist/jsnac-0.2.0-py3-none-any.whl differ diff --git a/dist/jsnac-0.2.0.tar.gz b/dist/jsnac-0.2.0.tar.gz new file mode 100644 index 0000000..8475346 Binary files /dev/null and b/dist/jsnac-0.2.0.tar.gz differ diff --git a/manage.sh b/docker.sh similarity index 100% rename from manage.sh rename to docker.sh diff --git a/docs/source/conf.py b/docs/source/conf.py index c0b95f5..2dc3187 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -9,7 +9,7 @@ project = "JSNAC" copyright = "2024, Andrew Jones" author = "Andrew Jones" -release = "0.1.0" +release = "0.2.0" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/docs/source/examples.rst b/docs/source/examples.rst index 1794059..32cc3e3 100644 --- a/docs/source/examples.rst +++ b/docs/source/examples.rst @@ -45,7 +45,7 @@ Library usage: # jsnac.add_json(json_data) # Build the JSON schema - schema = jsnac.build() + schema = jsnac.build_schema() print(schema) if __name__ == '__main__': diff --git a/docs/source/intro.rst b/docs/source/intro.rst index 4dc5056..282ae35 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -3,11 +3,11 @@ Introduction The majority of Network and Infrastructure automation is done in YAML. Be it Ansible Host Variables, Network Device data to build a Jinja2 configuration with, or just a collection of data you want to run a script against to put into another product you have likely written a YAML file and have had to debug a typo or had to help another colleague build a new file for a new device. -In an ideal world you can (and should) put a lot of this data into a database or Network Source of Truth solution and pull from it so the validation is done for you. However, these solutions don't cover every case and generally don't play nice with a GIT CI/CD process so you still will likely end up creating some YAML files here and there. +In an ideal world you can (and should) put a lot of this data into a database or Network Source of Truth solution and pull from it so the validation is done for you. However, these solutions don't cover every use case so you will likely end up creating some YAML files here and there. -Using a JSON schema for validating your YAML is a good practice in a CI/CD world but is very cumbersome to create from scratch. +Using a JSON schema for validating & documenting your YAML is a good practice in a CI/CD world but is very cumbersome to create from scratch. -This project aims to simplify this whole process to help you build a decent JSON schema base from a YAML file with network and infrastructure data definitions in mind. +This project aims to simplify this whole process by helping you build a JSON schema using YAML syntax that has network and infrastructure templates (or sub-schemas) in mind. Now you can hopefully catch those rare mistakes before you run that Playbook, create that configuration with a Jinja2 template or run a REST query to that Source of Truth or Telemetry solution :) @@ -40,49 +40,122 @@ You can simply write out how you would like to validate this data, and this prog .. code-block:: yaml - chassis: - hostname: - jsnac_type: pattern - jsnac_pattern: "^ceos-[a-zA-Z]{1,16}[0-9]$" - model: "ceos" - type: - jsnac_type: choice - jsnac_choices: ["router", "switch", "spine", "leaf"] + header: + title: "Ansible host vars" - system: - domain_name: - jsnac_type: domain - ntp_servers: - jsnac_type: ipv4 - - interfaces: - - if: - jsnac_type: string - desc: - jsnac_type: string - ipv4: - jsnac_type: ipv4_cidr - ipv6: - jsnac_type: ipv6_cidr - -Alternatively, you can also just use dictionaries inplace if you prefer that style of formatting: + schema: + chassis: + title: "Chassis" + type: "object" + properties: + hostname: + kind: { name: "string" } + model: + kind: { name: "string" } + device_type: + kind: { name: "choice", choices: [ "router", "switch", "firewall", "load-balancer" ] } + system: + type: "object" + properties: + domain_name: + kind: { name: "string" } + ntp_servers: + type: "array" + items: + kind: { name: "ipv4" } + interfaces: + type: "array" + items: + type: "object" + properties: + if: + kind: { name: "string" } + desc: + kind: { name: "string" } + ipv4: + kind: { name: "ipv4_cidr" } + ipv6: + kind: { name: "ipv6_cidr" } + +We also have full support for writing your own titles, descriptions, kinds (sub-schemas), objects that are required, etc. A more fleshed out example of the same schema is below: .. code-block:: yaml - chassis: - hostname: { jsnac_type: pattern, jsnac_pattern: "^ceos-[a-zA-Z]{1,16}[0-9]$" } - model: "ceos" - type: { jsnac_type: choice, jsnac_choices: ["router", "switch", "spine", "leaf"] } + header: + id: "example-schema.json" + title: "Ansible host vars" + description: | + Ansible host vars for my networking device. Requires the below objects: + - chassis + - system + - interfaces - system: - domain_name: { jsnac_type: domain } - ntp_servers: { jsnac_type: ipv4 } - - interfaces: - - if: { jsnac_type: string } - desc: { jsnac_type: string } - ipv4: { jsnac_type: ipv4_cidr } - ipv6: { jsnac_type: ipv6_cidr } + kinds: + hostname: + title: "Hostname" + description: "Hostname of the device" + type: "pattern" + regex: "^[a-zA-Z0-9-]{1,63}$" + + schema: + chassis: + title: "Chassis" + description: | + Object containing Chassis information. Has the below properties: + hostname [required]: hostname + model [required]: string + device_type [required]: choice (router, switch, firewall, load-balancer) + type: "object" + properties: + hostname: + kind: { name: "hostname" } + model: + kind: { name: "string" } + device_type: + title: "Device Type" + description: | + Device Type options are: + router, switch, firewall, load-balancer + kind: { name: "choice", choices: [ "router", "switch", "firewall", "load-balancer" ] } + required: [ "hostname", "model", "device_type" ] + system: + title: "System" + description: | + Object containing System information. Has the below properties: + domain_name [required]: string + ntp_servers [required]: list of ipv4 addresses + type: "object" + properties: + domain_name: + kind: { name: "string" } + ntp_servers: + title: "NTP Servers" + description: "List of NTP servers" + type: "array" + items: + kind: { name: "ipv4" } + required: [ "domain_name", "ntp_servers" ] + interfaces: + title: "Device Interfaces" + description: | + List of device interfaces. Each interface has the below properties: + if [required]: string + desc: string + ipv4: ipv4_cidr + ipv6: ipv6_cidr + type: "array" + items: + type: "object" + properties: + if: + kind: { name: "string" } + desc: + kind: { name: "string" } + ipv4: + kind: { name: "ipv4_cidr" } + ipv6: + kind: { name: "ipv6_cidr" } + required: [ "if" ] Motivation ********** @@ -95,4 +168,5 @@ Limitations *********** - This is a very basic package in its current status and is not designed to be used in a production environment. -- I am working on this in my free time and I am not a professional developer, so updates will be slow. \ No newline at end of file +- I am working on this in my free time and I am not a professional developer, so updates will be slow. +- Updates will likely completely change how this works as I continue to learn and grow my Python skills \ No newline at end of file diff --git a/docs/source/types.rst b/docs/source/types.rst index 8ac1e46..74cd105 100644 --- a/docs/source/types.rst +++ b/docs/source/types.rst @@ -1,24 +1,24 @@ -JSNAC Types +JSNAC Kinds =========== -See the following sections for details on the jsnac_types you can use in your YAML file(s). +See the following sections for details on the included JSNAC kinds you can use in your YAML file(s). -jsnac_type: pattern +kind: pattern ******************* This type is used to validate a string against a regular expression pattern. -The pattern should be a valid regex pattern that will be used to validate the string. +The pattern should be a valid regex pattern that will be used to validate the string. +If you are going to use this more than once, it is recommended to use the kinds section so you can reuse the pattern. **Example** .. code-block:: yaml chassis: - hostname: - jsnac_type: pattern - jsnac_pattern: "^ceos-[a-zA-Z]{1,16}[0-9]$" + hostname: + kind: { name: "pattern", pattern: "^[a-zA-Z0-9-]{1,63}$" } -jsnac_type: choice +kind: choice ****************** This type is used to validate a string against a list of choices. @@ -30,10 +30,9 @@ The choices should be a list of strings that the string will be validated agains chassis: type: - jsnac_type: choice - jsnac_choices: ["router", "switch", "spine", "leaf"] + kind: { name: "choice", choices: ["router", "switch", "firewall"] } -jsnac_type: domain +kind: domain ****************** This type is used to validate a string against a domain name. @@ -49,4 +48,22 @@ The string will be validated against the below domain name regex pattern. system: domain_name: - jsnac_type: domain \ No newline at end of file + kind: { name: "domain" } + +kind: ipv4 +****************** + +This type is used to validate a string against an IPv4 address. +The string will be validated against the below IPv4 address regex pattern. + +.. code-block:: text + + ^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$ + +**Example** + +.. code-block:: yaml + + system: + ip_address: + kind: { name: "ipv4" } \ No newline at end of file diff --git a/jsnac/core/infer.py b/jsnac/core/infer.py index ae0dec8..7b74484 100755 --- a/jsnac/core/infer.py +++ b/jsnac/core/infer.py @@ -2,17 +2,26 @@ import json import logging +from typing import ClassVar import yaml class SchemaInferer: """ - SchemaInferer is a class designed to infer JSON schemas from provided JSON or YAML data. + SchemaInferer is a class that infers JSON schemas from provided JSON or YAML data. + + user_defined_kinds (dict): A class variable that stores user-defined kinds. Methods: - __init__() -> None: - Initializes the instance of the class, setting up a logger for the class instance. + __init__(): + Initializes the instance of the class, setting up a logger. + + _view_user_defined_kinds() -> dict: + Returns the user-defined kinds currently stored in the class variable. + + _add_user_defined_kinds(kinds: dict) -> None: + Adds user-defined kinds to the class variable. add_json(json_data: str) -> None: Parses the provided JSON data and stores it in the instance. @@ -20,14 +29,31 @@ class SchemaInferer: add_yaml(yaml_data: str) -> None: Parses the provided YAML data, converts it to JSON format, and stores it in the instance. - build() -> dict: - Builds a JSON schema based on the data added to the schema inferer. + build_schema() -> str: + Builds a JSON schema based on the data added to the schema inferer. Returns the constructed schema. + + _build_definitions(data: dict) -> dict: + Builds the definitions section of the JSON schema. + + _build_properties(data: dict) -> dict: + Builds the properties section of the JSON schema. + + _build_property(obj: str, obj_data: dict) -> dict: + Builds a property for the JSON schema. + + _build_property_type(obj: str, obj_data: dict) -> dict: + Builds the type for a property in the JSON schema. - infer_properties(data: dict) -> dict: - Infers the JSON schema properties for the given data. + _build_array_items(obj: str, obj_data: dict) -> dict: + Builds the items for an array property in the JSON schema. + + _build_kinds(obj: str, data: dict) -> dict: + Builds the kinds for a property in the JSON schema. """ + user_defined_kinds: ClassVar[dict] = {} + def __init__(self) -> None: """ Initializes the instance of the class. @@ -43,6 +69,14 @@ def __init__(self) -> None: self.log = logging.getLogger(__name__) self.log.addHandler(logging.NullHandler()) + @classmethod + def _view_user_defined_kinds(cls) -> dict: + return cls.user_defined_kinds + + @classmethod + def _add_user_defined_kinds(cls, kinds: dict) -> None: + cls.user_defined_kinds.update(kinds) + # Take in JSON data and confirm it is valid JSON def add_json(self, json_data: str) -> None: """ @@ -57,7 +91,7 @@ def add_json(self, json_data: str) -> None: """ try: load_json_data = json.loads(json_data) - self.log.debug("JSON content: \n %s", json.dumps(load_json_data, indent=4)) + self.log.debug("JSON content: \n%s", json.dumps(load_json_data, indent=4)) self.data = load_json_data except json.JSONDecodeError as e: msg = "Invalid JSON data: %s", e @@ -77,23 +111,22 @@ def add_yaml(self, yaml_data: str) -> None: """ try: load_yaml_data = yaml.safe_load(yaml_data) - self.log.debug("YAML content: \n %s", load_yaml_data) + self.log.debug("YAML content: \n%s", load_yaml_data) except yaml.YAMLError as e: msg = "Invalid YAML data: %s", e self.log.exception(msg) raise ValueError(msg) from e json_dump = json.dumps(load_yaml_data, indent=4) json_data = json.loads(json_dump) - self.log.debug("JSON content: \n %s", json_dump) + self.log.debug("JSON content: \n%s", json_dump) self.data = json_data - def build(self) -> str: + def build_schema(self) -> str: """ Builds a JSON schema based on the data added to the schema inferer. - - This methos builds the base schema including our custom definitions for common data types. - Properties are handled by the infer_properties method to infer the properties of the schema - based on the input data provided. + This method constructs a JSON schema using the data previously added via + `add_json` or `add_yaml` methods. It supports JSON Schema draft-07 by default, + but can be configured to use other drafts if needed. Returns: str: A JSON string representing the constructed schema. @@ -101,6 +134,14 @@ def build(self) -> str: Raises: ValueError: If no data has been added to the schema inferer. + Notes: + - The schema's metadata (e.g., $schema, title, $id, description) is derived + from the "header" section of the provided data. + - Additional sub-schemas (definitions) can be added via the "kinds" section + of the provided data. + - The schemas for individual and nested properties are constructed + based on the "schema" section of the provided data. + """ # Check if the data has been added if not hasattr(self, "data"): @@ -109,165 +150,236 @@ def build(self) -> str: raise ValueError(msg) data = self.data - self.log.debug("Building schema for: \n %s ", json.dumps(data, indent=4)) + self.log.debug("Building schema for: \n%s ", json.dumps(data, indent=4)) # Using draft-07 until vscode $dynamicRef support is added (https://github.com/microsoft/vscode/issues/155379) # Feel free to replace this with http://json-schema.org/draft/2020-12/schema if not using vscode. - # I want to fix this with a flag to the CLI to allow you to choose the draft version you want to use - # in the future (or insert your own). schema = { - "$schema": "http://json-schema.org/draft-07/schema", - "title": "JSNAC Created Schema", - "description": "The below schema was created by JSNAC (https://github.com/commitconfirmed/jsnac)", - "$defs": { - "ipv4": { - "type": "string", - "pattern": "^((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])$", # noqa: E501 - "title": "IPv4 Address", - "description": "IPv4 address (String) \n Format: xxx.xxx.xxx.xxx", - }, - # Decided to just go simple for now, may add more complex validation in the future from - # https://stackoverflow.com/questions/53497/regular-expression-that-matches-valid-ipv6-addresses - "ipv6": { - "type": "string", - "pattern": "^(([a-fA-F0-9]{1,4}|):){1,7}([a-fA-F0-9]{1,4}|:)$", - "title": "IPv6 Address", - "description": "Short IPv6 address (String) \n Accepts both full and short form addresses, link-local addresses, and IPv4-mapped addresses", # noqa: E501 - }, - "ipv4_cidr": { - "type": "string", - "pattern": "^((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])/(1[0-9]|[0-9]|2[0-9]|3[0-2])$", # noqa: E501 - "title": "IPv4 CIDR", - "description": "IPv4 CIDR (String) \n Format: xxx.xxx.xxx.xxx/xx", - }, - "ipv6_cidr": { - "type": "string", - "pattern": "(([a-fA-F0-9]{1,4}|):){1,7}([a-fA-F0-9]{1,4}|:)/(32|36|40|44|48|52|56|60|64|128)$", - "title": "IPv6 CIDR", - "description": "Full IPv6 CIDR (String) \n Format: xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx/xxx", - }, - "ipv4_prefix": { - "type": "string", - "title": "IPv4 Prefix", - "pattern": "^/(1[0-9]|[0-9]|2[0-9]|3[0-2])$", - "description": "IPv4 Prefix (String) \n Format: /xx between 0 and 32", - }, - "ipv6_prefix": { - "type": "string", - "title": "IPv6 Prefix", - "pattern": "^/(32|36|40|44|48|52|56|60|64|128)$", - "description": "IPv6 prefix (String) \n Format: /xx between 32 and 64 in increments of 4. also /128", # noqa: E501 - }, - "domain": { - "type": "string", - "pattern": "^([a-zA-Z0-9-]{1,63}\\.)+[a-zA-Z]{2,63}$", - "title": "Domain Name", - "description": "Domain name (String) \n Format: example.com", - }, - # String is a default type, but in this instance we restict it to - # alphanumeric + special characters with a max length of 255. - "string": { - "type": "string", - "pattern": "^[a-zA-Z0-9!@#$%^&*()_+-\\{\\}|:;\"'<>,.?/ ]{1,255}$", - "title": "String", - "description": "Alphanumeric string with special characters (String) \n Max length: 255", - }, - }, - "type": "object", - "additionalProperties": False, - "properties": self.infer_properties(data)["properties"], + "$schema": data.get("header", {}).get("schema", "http://json-schema.org/draft-07/schema#"), + "title": data.get("header", {}).get("title", "JSNAC created Schema"), + "$id": data.get("header", {}).get("id", "jsnac.schema.json"), + "description": data.get("header", {}).get("description", "https://github.com/commitconfirmed/jsnac"), + "$defs": self._build_definitions(data.get("kinds", {})), + "type": data.get("type", "object"), + "additionalProperties": data.get("additionalProperties", False), + "properties": self._build_properties(data.get("schema", {})), } return json.dumps(schema, indent=4) - def infer_properties(self, data: str) -> dict: # noqa: C901 PLR0912 PLR0915 (To be fixed) + def _build_definitions(self, data: dict) -> dict: """ - Infers the JSON schema properties for the given data. - - This method analyzes the input data and generates a corresponding JSON schema. - It supports custom schema definitions based on the "jsnac_type" key in the input dictionary. + Build a dictionary of definitions based on predefined types and additional kinds provided in the input data. Args: - data (str): The input data to infer the schema from. + data (dict): A dictionary containing additional kinds to be added to the definitions. Returns: - dict: A dictionary representing the inferred JSON schema. + dict: A dictionary containing definitions for our predefined types such as 'ipv4', 'ipv6', etc. + Additional kinds from the input data are also included. - Schema Inference rules (based on the input data type): - - Is a dictionary and contains the "jsnac_type" key, the method uses custom schema definitions - - Is a dictionary without the "jsnac_type" key, we infer the schema recursively for each key-value pair. - - Is a list, the method infers the schema for the first item in the list. - - Is a string, integer, float, or boolean, the method infers the corresponding JSON schema type. - - Is Of an unrecognized type, the method defaults to a null schema. + Raises: + None """ - schema = {} - # Check if the dictionary has a jsnac_type key in it, then we know we can use our custom schema definitions - if isinstance(data, dict): - if "jsnac_type" in data: # Split this out into a separate method to be ruff compliant - match data["jsnac_type"]: - case "ipv4": - schema["$ref"] = "#/$defs/ipv4" - case "ipv6": - schema["$ref"] = "#/$defs/ipv6" - case "ipv4_cidr": - schema["$ref"] = "#/$defs/ipv4_cidr" - case "ipv6_cidr": - schema["$ref"] = "#/$defs/ipv6_cidr" - case "ipv4_prefix": - schema["$ref"] = "#/$defs/ipv4_prefix" - case "ipv6_prefix": - schema["$ref"] = "#/$defs/ipv6_prefix" - case "domain": - schema["$ref"] = "#/$defs/domain" - case "string": - schema["$ref"] = "#/$defs/string" - case "pattern": - if "jsnac_pattern" not in data: - self.log.error("jsnac_pattern key is required for jsnac_type: pattern.") - schema["type"] = "null" - schema["title"] = "Error" - schema["description"] = "No jsnac_pattern key provided" - else: - schema["type"] = "string" - schema["pattern"] = data["jsnac_pattern"] - schema["title"] = "Custom Pattern" - schema["description"] = "Custom Pattern (regex) \n Pattern: " + data["jsnac_pattern"] - case "choice": - if "jsnac_choices" not in data: - self.log.error("jsnac_choices key is required for jsnac_type: choice.") - schema["enum"] = "Error" - schema["title"] = "Error" - schema["description"] = "No jsnac_choices key provided" - else: - schema["enum"] = data["jsnac_choices"] - schema["title"] = "Custom Choice" - schema["description"] = "Custom Choice (enum) \n Choices: " + ", ".join( - data["jsnac_choices"] - ) - case _: - self.log.error("Invalid jsnac_type: (%s), defaulting to null", data["jsnac_type"]) - schema["type"] = "null" - schema["title"] = "Error" - schema["description"] = "Invalid jsnac_type (" + data["jsnac_type"] + ") defined" - # If not, simply continue inferring the schema - else: - schema["type"] = "object" - schema["properties"] = {k: self.infer_properties(v) for k, v in data.items()} - - elif isinstance(data, list): - if len(data) > 0: - schema["type"] = "array" - schema["items"] = self.infer_properties(data[0]) + self.log.debug("Building definitions for: \n%s ", json.dumps(data, indent=4)) + definitions = { + # JSNAC defined data types + "ipv4": { + "type": "string", + "pattern": "^((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])$", # noqa: E501 + "title": "IPv4 Address", + "description": "IPv4 address (String) \n Format: xxx.xxx.xxx.xxx", + }, + # Decided to just go simple for now, may add more complex validation in the future from + # https://stackoverflow.com/questions/53497/regular-expression-that-matches-valid-ipv6-addresses + "ipv6": { + "type": "string", + "pattern": "^(([a-fA-F0-9]{1,4}|):){1,7}([a-fA-F0-9]{1,4}|:)$", + "title": "IPv6 Address", + "description": "Short IPv6 address (String) \n Accepts both full and short form addresses, link-local addresses, and IPv4-mapped addresses", # noqa: E501 + }, + "ipv4_cidr": { + "type": "string", + "pattern": "^((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])/(1[0-9]|[0-9]|2[0-9]|3[0-2])$", # noqa: E501 + "title": "IPv4 CIDR", + "description": "IPv4 CIDR (String) \n Format: xxx.xxx.xxx.xxx/xx", + }, + "ipv6_cidr": { + "type": "string", + "pattern": "(([a-fA-F0-9]{1,4}|):){1,7}([a-fA-F0-9]{1,4}|:)/(32|36|40|44|48|52|56|60|64|128)$", + "title": "IPv6 CIDR", + "description": "Full IPv6 CIDR (String) \n Format: xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx/xxx", + }, + "ipv4_prefix": { + "type": "string", + "title": "IPv4 Prefix", + "pattern": "^/(1[0-9]|[0-9]|2[0-9]|3[0-2])$", + "description": "IPv4 Prefix (String) \n Format: /xx between 0 and 32", + }, + "ipv6_prefix": { + "type": "string", + "title": "IPv6 Prefix", + "pattern": "^/(32|36|40|44|48|52|56|60|64|128)$", + "description": "IPv6 prefix (String) \n Format: /xx between 32 and 64 in increments of 4. also /128", + }, + "domain": { + "type": "string", + "pattern": "^([a-zA-Z0-9-]{1,63}\\.)+[a-zA-Z]{2,63}$", + "title": "Domain Name", + "description": "Domain name (String) \n Format: example.com", + }, + } + # Check passed data for additional kinds and add them to the definitions + for kind, kind_data in data.items(): + self.log.debug("Building custom kind (%s): \n%s ", kind, json.dumps(kind_data, indent=4)) + definitions[kind] = {} + definitions[kind]["title"] = kind_data.get("title", f"{kind}") + definitions[kind]["description"] = kind_data.get("description", f"Custom Kind: {kind}") + # Only support a custom kind of pattern for now, will add more in the future + match kind_data.get("type"): + case "pattern": + definitions[kind]["type"] = "string" + if "regex" in kind_data: + definitions[kind]["pattern"] = kind_data["regex"] + self._add_user_defined_kinds({kind: True}) + else: + self.log.error("regex key is required for kind (%s) with type pattern", kind) + definitions[kind]["type"] = "null" + definitions[kind]["title"] = "Error" + definitions[kind]["description"] = "No regex key provided" + case _: + self.log.error("Invalid type (%s) for kind (%s), defaulting to string", kind_data.get("type"), kind) + definitions[kind]["type"] = "string" + self.log.debug("Returned Definitions: \n%s ", json.dumps(definitions, indent=4)) + return definitions + + def _build_properties(self, data: dict) -> dict: + self.log.debug("Building properties for: \n%s ", json.dumps(data, indent=4)) + properties: dict = {} + stack = [(properties, data)] + + while stack: + current_properties, current_data = stack.pop() + for obj, obj_data in current_data.items(): + self.log.debug("Object: %s ", obj) + self.log.debug("Object Data: %s ", obj_data) + # Build the property for the object + current_properties[obj] = self._build_property(obj, obj_data) + # Check if there is a nested object or array type and add it to the stack + if "type" in obj_data and obj_data["type"] == "object" and "properties" in obj_data: + stack.append((current_properties[obj]["properties"], obj_data["properties"])) + elif "type" in obj_data and obj_data["type"] == "array" and "items" in obj_data: + item_data = obj_data["items"] + # Array is nested if it contains properties + if "properties" in item_data: + stack.append((current_properties[obj]["items"]["properties"], item_data["properties"])) + + self.log.debug("Returned Properties: \n%s ", json.dumps(properties, indent=4)) + return properties + + def _build_property(self, obj: str, obj_data: dict) -> dict: + self.log.debug("Building property for Object (%s): \n%s ", obj, json.dumps(obj_data, indent=4)) + property_dict: dict = {} + + if "title" in obj_data: + property_dict["title"] = obj_data["title"] + if "description" in obj_data: + property_dict["description"] = obj_data["description"] + if "type" in obj_data: + property_dict.update(self._build_property_type(obj, obj_data)) + elif "kind" in obj_data: + property_dict.update(self._build_kinds(obj, obj_data["kind"])) + + if "required" in obj_data: + property_dict["required"] = obj_data["required"] + + self.log.debug("Returned Property: \n%s ", json.dumps(property_dict, indent=4)) + return property_dict + + def _build_property_type(self, obj: str, obj_data: dict) -> dict: + self.log.debug("Building property type for Object (%s): \n%s ", obj, json.dumps(obj_data, indent=4)) + property_type = {"type": obj_data["type"]} + match obj_data["type"]: + case "object": + property_type["properties"] = {} + case "array": + property_type.update(self._build_array_items(obj, obj_data)) + case _: + self.log.error("Invalid type (%s), defaulting to Null", obj_data["type"]) + property_type["type"] = "null" + self.log.debug("Returned Property Type: \n%s ", json.dumps(property_type, indent=4)) + return property_type + + def _build_array_items(self, obj: str, obj_data: dict) -> dict: + self.log.debug("Building array items for Object (%s): \n%s ", obj, json.dumps(obj_data, indent=4)) + array_items = {} + if "items" in obj_data: + item_data = obj_data["items"] + if "type" in item_data: + array_items["items"] = {"type": item_data["type"]} + if "properties" in item_data: + array_items["items"]["properties"] = {} + if "required" in item_data: + array_items["items"]["required"] = item_data["required"] + elif "kind" in item_data: + array_items["items"] = self._build_kinds(obj, item_data["kind"]) else: - schema["type"] = "array" - schema["items"] = {} - elif isinstance(data, str): - schema["type"] = "string" - elif isinstance(data, int): - schema["type"] = "integer" - elif isinstance(data, float): - schema["type"] = "number" - elif isinstance(data, bool): - schema["type"] = "boolean" + self.log.error("Array items require a type or kind key") + array_items["items"] = {"type": "null"} else: - schema["type"] = "null" - return schema + self.log.error("Array type requires an items key") + array_items["items"] = {"type": "null"} + self.log.debug("Returned Array Items: \n%s ", json.dumps(array_items, indent=4)) + return array_items + + def _build_kinds(self, obj: str, data: dict) -> dict: # noqa: C901 PLR0912 + self.log.debug("Building kinds for Object (%s): \n%s ", obj, json.dumps(data, indent=4)) + kind: dict = {} + # Check if the kind has a type, if so we will continue to dig depper until kinds are found + # I should update this to be ruff compliant, but it makes sense to me at the moment + match data.get("name"): + # Kinds with regex patterns + case "ipv4": + kind["$ref"] = "#/$defs/ipv4" + case "ipv6": + kind["$ref"] = "#/$defs/ipv6" + case "ipv4_cidr": + kind["$ref"] = "#/$defs/ipv4_cidr" + case "ipv6_cidr": + kind["$ref"] = "#/$defs/ipv6_cidr" + case "ipv4_prefix": + kind["$ref"] = "#/$defs/ipv4_prefix" + case "ipv6_prefix": + kind["$ref"] = "#/$defs/ipv6_prefix" + case "domain": + kind["$ref"] = "#/$defs/domain" + # For the choice kind, read the choices object + case "choice": + if "choices" in data: + kind["enum"] = data["choices"] + else: + self.log.error("Choice kind requires a choices object") + kind["description"] = "Choice kind requires a choices object" + kind["type"] = "null" + # Default types + case "string": + kind["type"] = "string" + kind["title"] = obj + kind["description"] = "String" + case "number": + kind["type"] = "number" + kind["description"] = "Integer or Float" + case "boolean": + kind["type"] = "boolean" + kind["description"] = "Boolean" + case "null": + kind["type"] = "null" + kind["description"] = "Null" + case _: + # Check if the kind is a user defined kind + if data.get("name") in self._view_user_defined_kinds(): + kind["$ref"] = "#/$defs/{}".format(data["name"]) + else: + self.log.error("Invalid kind (%s), defaulting to Null", data) + kind["description"] = f"Invalid kind ({data}), defaulting to Null" + kind["type"] = "null" + return kind diff --git a/jsnac/utils/jsnac_cli.py b/jsnac/utils/jsnac_cli.py index f0f9e0f..8dff9cb 100755 --- a/jsnac/utils/jsnac_cli.py +++ b/jsnac/utils/jsnac_cli.py @@ -138,7 +138,7 @@ def main(args: str | None = None) -> None: f.close() # Build the schema and record the time taken tic = time.perf_counter() - schema = jsnac.build() + schema = jsnac.build_schema() toc = time.perf_counter() duration = toc - tic log.info("Schema built in %.4f seconds", duration) diff --git a/pyproject.toml b/pyproject.toml index fd46763..5f85ff1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "jsnac" -version = "0.1.0" -description = "JSON Schema (for) Network as Code: Build JSON schemas from (un)modified YAML files" +version = "0.2.0" +description = "JSON Schema (for) Network as Code: Build JSON schemas from YAML" authors = ["Andrew Jones "] license = "MIT" readme = "README.md" diff --git a/tests/test_infer.py b/tests/test_infer.py index 1538e7c..b5bf357 100755 --- a/tests/test_infer.py +++ b/tests/test_infer.py @@ -1,34 +1,37 @@ #!/usr/bin/env python3 -from jsnac.core.infer import SchemaInferer - - -# Test SchemaInferer with jsnac_type: ipv4 -def test_infer_ipv4() -> None: - data = {"jsnac_type": "ipv4"} - schema = SchemaInferer().infer_properties(data) - assert schema == {"$ref": "#/$defs/ipv4"} - +import json -# Test SchemaInferer with jsnac_type: ipv6 -def test_infer_ipv6() -> None: - data = {"jsnac_type": "ipv6"} - schema = SchemaInferer().infer_properties(data) - assert schema == {"$ref": "#/$defs/ipv6"} - - -# Test SchemaInferer with jsnac_type: ipv4_cidr -def test_infer_ipv4_cidr() -> None: - data = {"jsnac_type": "ipv4_cidr"} - schema = SchemaInferer().infer_properties(data) - assert schema == {"$ref": "#/$defs/ipv4_cidr"} +from jsnac.core.infer import SchemaInferer -# Test SchemaInferer with an invalid jsnac_type -def test_infer_invalid_type() -> None: - data = {"jsnac_type": "invalid"} - schema = SchemaInferer().infer_properties(data) - assert schema == { - "type": "null", - "title": "Error", - "description": "Invalid jsnac_type (invalid) defined", +# Test that custom headers can be set +def test_custom_headers() -> None: + data = { + "header": { + "schema": "http://json-schema.org/draft/2020-12/schema", + "title": "Test Title", + "id": "test-schema.json", + "description": "Test Description", + } } + jsnac = SchemaInferer() + jsnac.add_json(json.dumps(data)) + schema = json.loads(jsnac.build_schema()) + assert schema["$schema"] == "http://json-schema.org/draft/2020-12/schema" + assert schema["title"] == "Test Title" + assert schema["$id"] == "test-schema.json" + assert schema["description"] == "Test Description" + + +# Test that default headers are set +def test_default_headers() -> None: + data = {"header": {}} + jsnac = SchemaInferer() + jsnac.add_json(json.dumps(data)) + schema = json.loads(jsnac.build_schema()) + assert schema["$schema"] == "http://json-schema.org/draft-07/schema#" + assert schema["title"] == "JSNAC created Schema" + assert schema["$id"] == "jsnac.schema.json" + assert schema["description"] == "https://github.com/commitconfirmed/jsnac" + assert schema["type"] == "object" + assert schema["properties"] == {}