diff --git a/go.mod b/go.mod index ed29d67..1395588 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/marcusolsson/json-schema-docs +module github.com/yogesh-badke/json-schema-docs go 1.14 diff --git a/ref.go b/ref.go index 36347ea..a19fc0f 100644 --- a/ref.go +++ b/ref.go @@ -45,6 +45,100 @@ func resolveSchema(schem *schema, dir string, root *simplejson.Json) (*schema, e *schem.Items = *foo } + // Resolve allOf schemas + for _, allOfSchema := range schem.AllOf { + if allOfSchema.Ref != "" { + tmp, err := resolveReference(allOfSchema.Ref, dir, root) + if err != nil { + return nil, err + } + *allOfSchema = *tmp + } + resolved, err := resolveSchema(allOfSchema, dir, root) + if err != nil { + return nil, err + } + *allOfSchema = *resolved + } + + // Resolve anyOf schemas + for _, anyOfSchema := range schem.AnyOf { + if anyOfSchema.Ref != "" { + tmp, err := resolveReference(anyOfSchema.Ref, dir, root) + if err != nil { + return nil, err + } + *anyOfSchema = *tmp + } + resolved, err := resolveSchema(anyOfSchema, dir, root) + if err != nil { + return nil, err + } + *anyOfSchema = *resolved + } + + // Resolve oneOf schemas + for _, oneOfSchema := range schem.OneOf { + if oneOfSchema.Ref != "" { + tmp, err := resolveReference(oneOfSchema.Ref, dir, root) + if err != nil { + return nil, err + } + *oneOfSchema = *tmp + } + resolved, err := resolveSchema(oneOfSchema, dir, root) + if err != nil { + return nil, err + } + *oneOfSchema = *resolved + } + + // Resolve if/then/else schemas + if schem.If != nil { + if schem.If.Ref != "" { + tmp, err := resolveReference(schem.If.Ref, dir, root) + if err != nil { + return nil, err + } + *schem.If = *tmp + } + resolved, err := resolveSchema(schem.If, dir, root) + if err != nil { + return nil, err + } + *schem.If = *resolved + } + + if schem.Then != nil { + if schem.Then.Ref != "" { + tmp, err := resolveReference(schem.Then.Ref, dir, root) + if err != nil { + return nil, err + } + *schem.Then = *tmp + } + resolved, err := resolveSchema(schem.Then, dir, root) + if err != nil { + return nil, err + } + *schem.Then = *resolved + } + + if schem.Else != nil { + if schem.Else.Ref != "" { + tmp, err := resolveReference(schem.Else.Ref, dir, root) + if err != nil { + return nil, err + } + *schem.Else = *tmp + } + resolved, err := resolveSchema(schem.Else, dir, root) + if err != nil { + return nil, err + } + *schem.Else = *resolved + } + return schem, nil } diff --git a/schema.go b/schema.go index f2cbe86..bf47bfa 100644 --- a/schema.go +++ b/schema.go @@ -26,6 +26,18 @@ type schema struct { Items *schema `json:"items,omitempty"` Definitions map[string]*schema `json:"definitions,omitempty"` Enum []Any `json:"enum"` + AllOf []*schema `json:"allOf,omitempty"` + AnyOf []*schema `json:"anyOf,omitempty"` + OneOf []*schema `json:"oneOf,omitempty"` + If *schema `json:"if,omitempty"` + Then *schema `json:"then,omitempty"` + Else *schema `json:"else,omitempty"` + Const interface{} `json:"const,omitempty"` + + // Internal fields to track conditional requirements (not from JSON) + conditionalRequirements map[string][]string // property name -> list of conditions + modeVariants map[string]map[string]*schema // property name -> (mode value -> schema variant) + controllingProperty string // name of the property that controls variants (e.g., "mode") } func newSchema(r io.Reader, workingDir string) (*schema, error) { @@ -45,7 +57,15 @@ func newSchema(r io.Reader, workingDir string) (*schema, error) { return nil, err } - return resolveSchema(&data, workingDir, root) + resolved, err := resolveSchema(&data, workingDir, root) + if err != nil { + return nil, err + } + + // Merge conditional requirements into the schema + mergeConditionalRequirements(resolved) + + return resolved, nil } // Markdown returns the Markdown representation of the schema. @@ -79,6 +99,9 @@ func (s schema) Markdown(level int) string { // Add padding. fmt.Fprintln(&buf) + // Generate mode-specific variant sections + printModeVariants(&buf, &s, level+1) + for _, obj := range findDefinitions(&s) { fmt.Fprint(&buf, obj.Markdown(level+1)) } @@ -130,6 +153,285 @@ func findDefinitions(s *schema) []*schema { return objs } +// mergeConditionalRequirements analyzes conditional requirements and tracks them for display +func mergeConditionalRequirements(s *schema) { + // Recursively process all properties first + for _, prop := range s.Properties { + mergeConditionalRequirements(prop) + } + + if s.Items != nil { + mergeConditionalRequirements(s.Items) + } + + // Collect conditional requirements from allOf + for _, allOfSchema := range s.AllOf { + // If this allOf entry has if/then, extract condition and requirements + if allOfSchema.If != nil && allOfSchema.Then != nil { + controlProp, modeValue := extractConditionParts(allOfSchema.If) + if controlProp != "" && modeValue != "" { + // Track for Option 1 (inline conditions) + condition := fmt.Sprintf("`%s=%v`", controlProp, modeValue) + trackConditionalRequirements(s, allOfSchema.Then, condition, []string{}) + + // Track for Option 4 (mode-specific variants) + extractModeVariants(s, allOfSchema.Then, controlProp, modeValue) + } + } + } +} + +// extractCondition extracts a human-readable condition from an if schema +func extractCondition(ifSchema *schema) string { + propName, value := extractConditionParts(ifSchema) + if propName != "" && value != "" { + return fmt.Sprintf("`%s=%v`", propName, value) + } + return "" +} + +// extractConditionParts extracts the property name and const value from an if schema +func extractConditionParts(ifSchema *schema) (string, string) { + if ifSchema == nil || ifSchema.Properties == nil { + return "", "" + } + + // Navigate through nested properties to find the const value + for propName, prop := range ifSchema.Properties { + if prop.Properties != nil { + // Nested property, recurse (e.g., deployment.mode) + for nestedProp, nestedSchema := range prop.Properties { + if nestedSchema.Const != nil { + return nestedProp, fmt.Sprintf("%v", nestedSchema.Const) + } + } + } + if prop.Const != nil { + return propName, fmt.Sprintf("%v", prop.Const) + } + } + + return "", "" +} + +// extractModeVariants extracts and stores mode-specific property variants recursively +func extractModeVariants(target *schema, source *schema, controlProp string, modeValue string) { + if source == nil || source.Properties == nil { + return + } + + // For each property in the source + for propName, sourceProp := range source.Properties { + // If this property exists in the target + if targetProp, exists := target.Properties[propName]; exists { + // Check if this property has a "required" array with properties that exist in target + if len(sourceProp.Required) > 0 && targetProp.Properties != nil { + // Only create mode variants if the required properties are "leaf" properties + // (not object types that themselves have properties) + // This prevents creating variant sections for parent objects + hasLeafVariants := true + + // If there's only ONE required property and it's an object type, + // skip creating variants here - the variants should be on that child object instead + if len(sourceProp.Required) == 1 { + for _, reqPropName := range sourceProp.Required { + if actualProp, exists := targetProp.Properties[reqPropName]; exists { + if actualProp.Type.HasType(PropertyTypeObject) { + hasLeafVariants = false + break + } + } + } + } + + if hasLeafVariants { + // This is a variant property with mode-specific requirements + // Initialize modeVariants map if needed + if targetProp.modeVariants == nil { + targetProp.modeVariants = make(map[string]map[string]*schema) + } + + // Store the controlling property name + if targetProp.controllingProperty == "" { + targetProp.controllingProperty = controlProp + } + + // Store each required property for this mode + for _, reqPropName := range sourceProp.Required { + // Get the actual property definition from the target + if actualProp, exists := targetProp.Properties[reqPropName]; exists { + if targetProp.modeVariants[reqPropName] == nil { + targetProp.modeVariants[reqPropName] = make(map[string]*schema) + } + + // Store this property as required for this mode + targetProp.modeVariants[reqPropName][modeValue] = actualProp + } + } + } + } + + // Recursively process nested properties at any depth + if sourceProp.Properties != nil && targetProp.Properties != nil { + extractModeVariants(targetProp, sourceProp, controlProp, modeValue) + } + } + } +} + +// trackConditionalRequirements tracks which properties are required under which conditions +func trackConditionalRequirements(target *schema, source *schema, condition string, path []string) { + if source == nil || source.Properties == nil { + return + } + + // For each property in the source + for propName, sourceProp := range source.Properties { + // If this property exists in the target + if targetProp, exists := target.Properties[propName]; exists { + // Initialize conditionalRequirements map if needed + if targetProp.conditionalRequirements == nil { + targetProp.conditionalRequirements = make(map[string][]string) + } + + // Track conditional requirements for this property + for _, req := range sourceProp.Required { + if !containsString(targetProp.conditionalRequirements[req], condition) { + targetProp.conditionalRequirements[req] = append(targetProp.conditionalRequirements[req], condition) + } + } + + // Recursively track nested properties + if sourceProp.Properties != nil && targetProp.Properties != nil { + trackConditionalRequirements(targetProp, sourceProp, condition, append(path, propName)) + } + } + } +} + +// containsString checks if a string slice contains a specific string +func containsString(slice []string, str string) bool { + for _, s := range slice { + if s == str { + return true + } + } + return false +} + +// printNestedObjects recursively prints all nested object properties +func printNestedObjects(w io.Writer, s *schema, level int) { + for _, nestedObj := range findDefinitions(s) { + fmt.Fprintln(w, makeHeading(nestedObj.Title, level)) + fmt.Fprintln(w) + if nestedObj.Description != "" { + fmt.Fprintln(w, nestedObj.Description) + fmt.Fprintln(w) + } + fmt.Fprintln(w, makeHeading("Properties", level+1)) + fmt.Fprintln(w) + printProperties(w, nestedObj) + fmt.Fprintln(w) + + // Recursively print deeper nested objects + printNestedObjects(w, nestedObj, level+1) + } +} + +// printModeVariants generates mode-specific sections for properties with variants +func printModeVariants(w io.Writer, s *schema, level int) { + // Find properties that have mode variants + for propName, prop := range s.Properties { + if len(prop.modeVariants) > 0 && prop.controllingProperty != "" { + // Collect all mode values from the controlling property's enum + modeValuesMap := make(map[string]bool) + + // Get mode values from the controlling property's enum + if controlProp, exists := s.Properties[prop.controllingProperty]; exists { + if len(controlProp.Enum) > 0 { + for _, enumVal := range controlProp.Enum { + modeValuesMap[enumVal.String()] = true + } + } + } + + // If no enum values found, fall back to mode variants + if len(modeValuesMap) == 0 { + for _, variants := range prop.modeVariants { + for modeValue := range variants { + modeValuesMap[modeValue] = true + } + } + } + + // Sort mode values for consistent output + var modeValues []string + for mode := range modeValuesMap { + modeValues = append(modeValues, mode) + } + sort.Strings(modeValues) + + // Generate a section for each mode value + for _, modeValue := range modeValues { + // Create heading with anchor-friendly format + fmt.Fprintln(w, makeHeading(fmt.Sprintf("%s-%s", propName, modeValue), level)) + fmt.Fprintln(w) + fmt.Fprintf(w, "_Used when `%s = \"%s\"`_\n", prop.controllingProperty, modeValue) + fmt.Fprintln(w) + + // Check if this mode has any required properties + hasRequiredProps := false + var modeProps []*schema + var modePropNames []string + + for variantPropName, variants := range prop.modeVariants { + if variantSchema, exists := variants[modeValue]; exists { + hasRequiredProps = true + modeProps = append(modeProps, variantSchema) + modePropNames = append(modePropNames, variantPropName) + } + } + + if !hasRequiredProps { + fmt.Fprintln(w, fmt.Sprintf("No additional configuration required for %s mode.", modeValue)) + fmt.Fprintln(w) + fmt.Fprintln(w, "---") + fmt.Fprintln(w) + continue + } + + // Create a temporary schema with just the properties for this mode + modeSchema := &schema{ + Properties: make(map[string]*schema), + } + + // Collect required properties for this mode + // All properties in modeVariants for this mode are required by definition + var requiredProps []string + for variantPropName, variants := range prop.modeVariants { + if _, exists := variants[modeValue]; exists { + modeSchema.Properties[variantPropName] = prop.Properties[variantPropName] + requiredProps = append(requiredProps, variantPropName) + } + } + modeSchema.Required = requiredProps + + // Print properties table for this mode + fmt.Fprintln(w, makeHeading("Properties", level+1)) + fmt.Fprintln(w) + printProperties(w, modeSchema) + fmt.Fprintln(w) + + // Recursively print nested object properties + printNestedObjects(w, modeSchema, level+2) + + fmt.Fprintln(w, "---") + fmt.Fprintln(w) + } + } + } +} + func printProperties(w io.Writer, s *schema) { table := tablewriter.NewWriter(w) table.SetHeader([]string{"Property", "Type", "Required", "Description"}) @@ -175,16 +477,41 @@ func printProperties(w io.Writer, s *schema) { propTypeStr = fmt.Sprintf("%s, or %s", strings.Join(propType[:len(propType)-1], ", "), propType[len(propType)-1]) } - // Emphasize required properties. + // Emphasize required properties (including conditional requirements). var required string + + // Check if unconditionally required if in(s.Required, k) { required = "**Yes**" + } else if s.conditionalRequirements != nil && len(s.conditionalRequirements[k]) > 0 { + // Property is conditionally required + conditions := s.conditionalRequirements[k] + if len(conditions) == 1 { + required = fmt.Sprintf("When %s", conditions[0]) + } else { + // Multiple conditions + required = fmt.Sprintf("When %s", strings.Join(conditions, " or ")) + } } else { required = "No" } desc := p.Description + // Check if this property has mode variants - if so, add hyperlinks to mode-specific sections + if len(p.modeVariants) > 0 && p.controllingProperty != "" { + // Get the enum values from the controlling property + if controlProp, exists := s.Properties[p.controllingProperty]; exists && len(controlProp.Enum) > 0 { + var modeLinks []string + for _, enumVal := range controlProp.Enum { + modeValue := enumVal.String() + anchor := fmt.Sprintf("%s-%s", k, modeValue) + modeLinks = append(modeLinks, fmt.Sprintf("[%s](#%s)", modeValue, anchor)) + } + desc += " See: " + strings.Join(modeLinks, ", ") + "." + } + } + if len(p.Enum) > 0 { var vals []string for _, e := range p.Enum { diff --git a/schema_test.go b/schema_test.go index 4cd7fe6..b002145 100644 --- a/schema_test.go +++ b/schema_test.go @@ -27,6 +27,7 @@ func TestSchema(t *testing.T) { {name: "ref-hell", schema: "ref-hell.schema.json"}, {name: "union", schema: "union.schema.json"}, {name: "deep-headings", schema: "ref-hell.schema.json", level: 5}, + {name: "conditional", schema: "conditional.schema.json"}, } for _, tt := range schemaTests { diff --git a/testdata/TestSchema_conditional.golden b/testdata/TestSchema_conditional.golden new file mode 100644 index 0000000..c2057f9 --- /dev/null +++ b/testdata/TestSchema_conditional.golden @@ -0,0 +1,144 @@ +# Redis Configuration + +## Properties + +| Property | Type | Required | Description | +|--------------|-----------------------|----------|-------------------------------| +| `deployment` | [object](#deployment) | **Yes** | Deployment mode configuration | + +## deployment + +Deployment mode configuration + +### Properties + +| Property | Type | Required | Description | +|----------|-------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `mode` | string | **Yes** | Redis deployment topology. **Standalone:** Single instance. **Sentinel:** HA with automatic failover. **Cluster:** Horizontal scaling with sharding. Possible values are: `standalone`, `sentinel`, `cluster`. | +| `config` | [object](#config) | When `mode=cluster` or `mode=sentinel` | Mode-specific configuration. Contents depend on deployment.mode: standalone/sentinel use replica and sentinel objects, cluster uses numShards and replicasPerShard. See: [standalone](#config-standalone), [sentinel](#config-sentinel), [cluster](#config-cluster). | + +### config-cluster + +_Used when `mode = "cluster"`_ + +#### Properties + +| Property | Type | Required | Description | +|--------------------|--------|----------|-------------------------------------------------------| +| `numShards` | number | **Yes** | Number of shards in cluster mode. **Default: `3`**. | +| `replicasPerShard` | number | **Yes** | Replicas per shard in cluster mode. **Default: `1`**. | + +--- + +### config-sentinel + +_Used when `mode = "sentinel"`_ + +#### Properties + +| Property | Type | Required | Description | +|------------|---------------------|----------|-------------------------------------------------------| +| `replica` | [object](#replica) | **Yes** | Replica configuration for standalone or sentinel mode | +| `sentinel` | [object](#sentinel) | **Yes** | Sentinel configuration for HA | + +##### replica + +Replica configuration for standalone or sentinel mode + +###### Properties + +| Property | Type | Required | Description | +|-------------|----------------------|----------|--------------------------------------------| +| `count` | number | **Yes** | Number of read replicas. **Default: `0`**. | +| `resources` | [object](#resources) | **Yes** | | + +###### resources + +**Properties** + +| Property | Type | Required | Description | +|------------|---------------------|----------|-------------| +| `requests` | [object](#requests) | **Yes** | | + +**requests** + +**Properties** + +| Property | Type | Required | Description | +|----------|--------|----------|-------------| +| `cpu` | string | **Yes** | | +| `memory` | string | **Yes** | | + +##### sentinel + +Sentinel configuration for HA + +###### Properties + +| Property | Type | Required | Description | +|------------|--------|----------|-------------------------------------------------------------------------------------| +| `quorum` | number | **Yes** | Quorum for failover. **Default: `2`**. | +| `replicas` | number | **Yes** | Number of Sentinel processes. **Default: `3`**. Possible values are: `3`, `5`, `7`. | + +--- + +### config-standalone + +_Used when `mode = "standalone"`_ + +No additional configuration required for standalone mode. + +--- + +### config + +Mode-specific configuration. Contents depend on deployment.mode: standalone/sentinel use replica and sentinel objects, cluster uses numShards and replicasPerShard. + +#### Properties + +| Property | Type | Required | Description | +|--------------------|---------------------|----------------------|-------------------------------------------------------| +| `numShards` | number | When `mode=cluster` | Number of shards in cluster mode. **Default: `3`**. | +| `replicasPerShard` | number | When `mode=cluster` | Replicas per shard in cluster mode. **Default: `1`**. | +| `replica` | [object](#replica) | When `mode=sentinel` | Replica configuration for standalone or sentinel mode | +| `sentinel` | [object](#sentinel) | When `mode=sentinel` | Sentinel configuration for HA | + +#### replica + +Replica configuration for standalone or sentinel mode + +##### Properties + +| Property | Type | Required | Description | +|-------------|----------------------|----------|--------------------------------------------| +| `count` | number | **Yes** | Number of read replicas. **Default: `0`**. | +| `resources` | [object](#resources) | **Yes** | | + +##### resources + +###### Properties + +| Property | Type | Required | Description | +|------------|---------------------|----------|-------------| +| `requests` | [object](#requests) | **Yes** | | + +###### requests + +**Properties** + +| Property | Type | Required | Description | +|----------|--------|----------|-------------| +| `cpu` | string | **Yes** | | +| `memory` | string | **Yes** | | + +#### sentinel + +Sentinel configuration for HA + +##### Properties + +| Property | Type | Required | Description | +|------------|--------|----------|-------------------------------------------------------------------------------------| +| `quorum` | number | **Yes** | Quorum for failover. **Default: `2`**. | +| `replicas` | number | **Yes** | Number of Sentinel processes. **Default: `3`**. Possible values are: `3`, `5`, `7`. | + diff --git a/testdata/conditional.schema.json b/testdata/conditional.schema.json new file mode 100644 index 0000000..6da4906 --- /dev/null +++ b/testdata/conditional.schema.json @@ -0,0 +1,145 @@ +{ + "title": "Redis Configuration", + "type": "object", + "allOf": [ + { + "if": { + "properties": { + "deployment": { + "properties": { + "mode": { + "const": "cluster" + } + } + } + } + }, + "then": { + "properties": { + "deployment": { + "required": ["config"], + "properties": { + "config": { + "required": ["numShards", "replicasPerShard"] + } + } + } + }, + "errorMessage": "deployment.mode 'cluster' requires config with numShards and replicasPerShard" + } + }, + { + "if": { + "properties": { + "deployment": { + "properties": { + "mode": { + "const": "sentinel" + } + } + } + } + }, + "then": { + "properties": { + "deployment": { + "required": ["config"], + "properties": { + "config": { + "required": ["replica", "sentinel"] + } + } + } + }, + "errorMessage": "deployment.mode 'sentinel' requires config with replica and sentinel objects" + } + } + ], + "properties": { + "deployment": { + "type": "object", + "description": "Deployment mode configuration", + "properties": { + "mode": { + "type": "string", + "enum": ["standalone", "sentinel", "cluster"], + "description": "Redis deployment topology. **Standalone:** Single instance. **Sentinel:** HA with automatic failover. **Cluster:** Horizontal scaling with sharding.", + "default": "standalone" + }, + "config": { + "type": "object", + "description": "Mode-specific configuration. Contents depend on deployment.mode: standalone/sentinel use replica and sentinel objects, cluster uses numShards and replicasPerShard.", + "properties": { + "replica": { + "type": "object", + "description": "Replica configuration for standalone or sentinel mode", + "properties": { + "count": { + "type": "number", + "minimum": 0, + "maximum": 5, + "description": "Number of read replicas. **Default: `0`**.", + "default": 0 + }, + "resources": { + "type": "object", + "properties": { + "requests": { + "type": "object", + "properties": { + "cpu": { + "type": "string", + "default": "500m" + }, + "memory": { + "type": "string", + "default": "1Gi" + } + }, + "required": ["cpu", "memory"] + } + }, + "required": ["requests"] + } + }, + "required": ["count", "resources"] + }, + "sentinel": { + "type": "object", + "description": "Sentinel configuration for HA", + "properties": { + "replicas": { + "type": "number", + "enum": [3, 5, 7], + "description": "Number of Sentinel processes. **Default: `3`**.", + "default": 3 + }, + "quorum": { + "type": "number", + "minimum": 2, + "description": "Quorum for failover. **Default: `2`**.", + "default": 2 + } + }, + "required": ["replicas", "quorum"] + }, + "numShards": { + "type": "number", + "minimum": 3, + "description": "Number of shards in cluster mode. **Default: `3`**.", + "default": 3 + }, + "replicasPerShard": { + "type": "number", + "minimum": 1, + "description": "Replicas per shard in cluster mode. **Default: `1`**.", + "default": 1 + } + } + } + }, + "required": ["mode"] + } + }, + "required": ["deployment"] +}