diff --git a/examples/app/comprehensive-validation.json b/examples/app/comprehensive-validation.json new file mode 100644 index 0000000..0a4b792 --- /dev/null +++ b/examples/app/comprehensive-validation.json @@ -0,0 +1,681 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "title": "Comprehensive Validation Demo", + "description": "A complete example showcasing all JSON Schema 2020-12 validation features", + "properties": { + "personalInfo": { + "type": "object", + "title": "Personal Information", + "properties": { + "firstName": { + "type": "string", + "title": "First Name", + "minLength": 2, + "maxLength": 50, + "pattern": "^[A-Za-z\\s]+$", + "ui": { + "element": "input", + "type": "text", + "class": "form-group", + "order": 1, + "placeholder": "Enter your first name" + } + }, + "lastName": { + "type": "string", + "title": "Last Name", + "minLength": 2, + "maxLength": 50, + "pattern": "^[A-Za-z\\s]+$", + "ui": { + "element": "input", + "type": "text", + "class": "form-group", + "order": 2 + } + }, + "age": { + "type": "integer", + "title": "Age", + "minimum": 18, + "maximum": 120, + "ui": { + "element": "input", + "type": "number", + "class": "form-group", + "order": 3 + } + }, + "email": { + "type": "string", + "format": "email", + "title": "Email Address", + "ui": { + "element": "input", + "type": "email", + "class": "form-group", + "order": 4 + } + }, + "phone": { + "type": "string", + "title": "Phone Number", + "pattern": "^\\+?[1-9]\\d{1,14}$", + "ui": { + "element": "input", + "type": "tel", + "class": "form-group", + "order": 5 + } + }, + "dateOfBirth": { + "type": "string", + "format": "date", + "title": "Date of Birth", + "ui": { + "element": "input", + "type": "date", + "class": "form-group", + "order": 6 + } + }, + "website": { + "type": "string", + "format": "uri", + "title": "Personal Website", + "ui": { + "element": "input", + "type": "url", + "class": "form-group", + "order": 7 + } + } + }, + "required": [ "firstName", "lastName", "email" ], + "dependentRequired": { + "phone": [ "email" ], + "website": [ "email" ] + } + }, + "address": { + "type": "object", + "title": "Address Information", + "properties": { + "street": { + "type": "string", + "title": "Street Address", + "minLength": 5, + "maxLength": 100, + "ui": { + "element": "input", + "type": "text", + "class": "form-group", + "order": 1 + } + }, + "city": { + "type": "string", + "title": "City", + "minLength": 2, + "maxLength": 50, + "ui": { + "element": "input", + "type": "text", + "class": "form-group", + "order": 2 + } + }, + "state": { + "type": "string", + "title": "State/Province", + "ui": { + "element": "input", + "type": "text", + "class": "form-group", + "order": 3 + } + }, + "country": { + "type": "string", + "title": "Country", + "enum": [ "US", "CA", "UK", "DE", "FR", "AU", "JP", "IN" ], + "ui": { + "element": "select", + "class": "form-group", + "order": 4, + "options": [ + { "value": "US", "text": "United States" }, + { "value": "CA", "text": "Canada" }, + { "value": "UK", "text": "United Kingdom" }, + { "value": "DE", "text": "Germany" }, + { "value": "FR", "text": "France" }, + { "value": "AU", "text": "Australia" }, + { "value": "JP", "text": "Japan" }, + { "value": "IN", "text": "India" } + ] + } + }, + "postalCode": { + "type": "string", + "title": "Postal Code", + "ui": { + "element": "input", + "type": "text", + "class": "form-group", + "order": 5 + } + } + }, + "required": [ "street", "city", "country" ], + "allOf": [ + { + "if": { + "properties": { + "country": { "const": "US" } + } + }, + "then": { + "properties": { + "postalCode": { + "pattern": "^\\d{5}(-\\d{4})?$" + } + } + } + }, + { + "if": { + "properties": { + "country": { "const": "CA" } + } + }, + "then": { + "properties": { + "postalCode": { + "pattern": "^[A-Z]\\d[A-Z] \\d[A-Z]\\d$" + } + } + } + } + ] + }, + "preferences": { + "type": "object", + "title": "User Preferences", + "properties": { + "newsletter": { + "type": "boolean", + "title": "Subscribe to Newsletter", + "default": false, + "ui": { + "element": "input", + "type": "checkbox", + "class": "form-group", + "order": 1 + } + }, + "notifications": { + "type": "array", + "title": "Notification Types", + "minItems": 1, + "maxItems": 5, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ "email", "sms", "push", "phone", "mail" ] + }, + "ui": { + "element": "select", + "class": "form-group", + "order": 2, + "multiple": true, + "options": [ + { "value": "email", "text": "Email Notifications" }, + { "value": "sms", "text": "SMS Notifications" }, + { "value": "push", "text": "Push Notifications" }, + { "value": "phone", "text": "Phone Calls" }, + { "value": "mail", "text": "Physical Mail" } + ] + } + }, + "theme": { + "type": "string", + "title": "Preferred Theme", + "enum": [ "light", "dark", "auto" ], + "default": "auto", + "ui": { + "element": "select", + "class": "form-group", + "order": 3, + "options": [ + { "value": "light", "text": "Light Theme" }, + { "value": "dark", "text": "Dark Theme" }, + { "value": "auto", "text": "Auto (System)" } + ] + } + }, + "language": { + "type": "string", + "title": "Preferred Language", + "enum": [ "en", "es", "fr", "de", "zh", "ja" ], + "default": "en", + "ui": { + "element": "select", + "class": "form-group", + "order": 4, + "options": [ + { "value": "en", "text": "English" }, + { "value": "es", "text": "Español" }, + { "value": "fr", "text": "Français" }, + { "value": "de", "text": "Deutsch" }, + { "value": "zh", "text": "中文" }, + { "value": "ja", "text": "日本語" } + ] + } + } + } + }, + "financial": { + "type": "object", + "title": "Financial Information", + "properties": { + "annualIncome": { + "type": "number", + "title": "Annual Income", + "minimum": 0, + "maximum": 10000000, + "multipleOf": 1000, + "ui": { + "element": "input", + "type": "number", + "class": "form-group", + "order": 1, + "step": "1000" + } + }, + "creditScore": { + "type": "integer", + "title": "Credit Score", + "minimum": 300, + "maximum": 850, + "ui": { + "element": "input", + "type": "range", + "class": "form-group", + "order": 2, + "min": "300", + "max": "850" + } + }, + "accountType": { + "type": "string", + "title": "Account Type", + "oneOf": [ + { + "const": "basic", + "title": "Basic Account", + "description": "Free account with basic features" + }, + { + "const": "premium", + "title": "Premium Account", + "description": "Paid account with advanced features" + }, + { + "const": "enterprise", + "title": "Enterprise Account", + "description": "Business account with full features" + } + ], + "ui": { + "element": "select", + "class": "form-group", + "order": 3, + "options": [ + { "value": "basic", "text": "Basic Account" }, + { "value": "premium", "text": "Premium Account" }, + { "value": "enterprise", "text": "Enterprise Account" } + ] + } + } + }, + "if": { + "properties": { + "accountType": { "const": "premium" } + } + }, + "then": { + "properties": { + "paymentMethod": { + "type": "string", + "title": "Payment Method", + "enum": [ "credit_card", "debit_card", "paypal", "bank_transfer" ], + "ui": { + "element": "select", + "class": "form-group", + "order": 4, + "options": [ + { "value": "credit_card", "text": "Credit Card" }, + { "value": "debit_card", "text": "Debit Card" }, + { "value": "paypal", "text": "PayPal" }, + { "value": "bank_transfer", "text": "Bank Transfer" } + ] + } + } + }, + "required": [ "paymentMethod" ] + }, + "else": { + "not": { + "required": [ "paymentMethod" ] + } + } + }, + "skills": { + "type": "array", + "title": "Technical Skills", + "minItems": 1, + "maxItems": 10, + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Skill Name", + "minLength": 2, + "maxLength": 50 + }, + "level": { + "type": "string", + "title": "Proficiency Level", + "enum": [ "beginner", "intermediate", "advanced", "expert" ] + }, + "yearsExperience": { + "type": "number", + "title": "Years of Experience", + "minimum": 0, + "maximum": 50, + "multipleOf": 0.5 + } + }, + "required": [ "name", "level" ] + }, + "ui": { + "element": "fieldset", + "class": "skills-section", + "order": 1 + } + }, + "portfolio": { + "type": "object", + "title": "Portfolio", + "properties": { + "projects": { + "type": "array", + "title": "Projects", + "maxItems": 5, + "items": { + "type": "object", + "properties": { + "title": { + "type": "string", + "title": "Project Title", + "minLength": 3, + "maxLength": 100 + }, + "description": { + "type": "string", + "title": "Description", + "maxLength": 500, + "ui": { + "element": "textarea", + "rows": 4, + "class": "form-group" + } + }, + "url": { + "type": "string", + "format": "uri", + "title": "Project URL" + }, + "technologies": { + "type": "array", + "title": "Technologies Used", + "items": { + "type": "string" + }, + "minItems": 1, + "maxItems": 10, + "uniqueItems": true + } + }, + "required": [ "title", "description" ] + } + } + }, + "anyOf": [ + { + "properties": { + "githubUrl": { + "type": "string", + "format": "uri", + "pattern": "^https://github\\.com/", + "title": "GitHub Profile" + } + } + }, + { + "properties": { + "linkedinUrl": { + "type": "string", + "format": "uri", + "pattern": "^https://www\\.linkedin\\.com/", + "title": "LinkedIn Profile" + } + } + }, + { + "properties": { + "personalWebsite": { + "type": "string", + "format": "uri", + "title": "Personal Website" + } + } + } + ] + }, + "comments": { + "type": "string", + "title": "Additional Comments", + "maxLength": 1000, + "ui": { + "element": "textarea", + "class": "form-group", + "rows": 5, + "placeholder": "Any additional information you'd like to share..." + } + }, + "termsAccepted": { + "type": "boolean", + "title": "I accept the terms and conditions", + "const": true, + "ui": { + "element": "input", + "type": "checkbox", + "class": "form-group required-checkbox" + } + }, + "privacyAccepted": { + "type": "boolean", + "title": "I accept the privacy policy", + "const": true, + "ui": { + "element": "input", + "type": "checkbox", + "class": "form-group required-checkbox" + } + } + }, + "required": [ "personalInfo", "address", "termsAccepted", "privacyAccepted" ], + "dependentSchemas": { + "financial": { + "properties": { + "personalInfo": { + "properties": { + "age": { + "minimum": 21 + } + } + } + } + } + }, + "form": { + "class": "comprehensive-form", + "action": "/api/submit-comprehensive-form", + "method": "POST", + "enctype": "multipart/form-data", + "groups": [ + { + "title": { + "text": "Personal Information", + "class": "section-header" + }, + "class": "personal-info-section", + "fields": [ + "personalInfo.firstName", + "personalInfo.lastName", + "personalInfo.age", + "personalInfo.email", + "personalInfo.phone", + "personalInfo.dateOfBirth", + "personalInfo.website" + ] + }, + { + "title": { + "text": "Address Information", + "class": "section-header" + }, + "class": "address-section flex flex-wrap gap-4", + "fields": [ + "address.street", + "address.city", + "address.state", + "address.country", + "address.postalCode" + ] + }, + { + "title": { + "text": "Preferences & Settings", + "class": "section-header" + }, + "class": "preferences-section", + "fields": [ + "preferences.newsletter", + "preferences.notifications", + "preferences.theme", + "preferences.language" + ] + }, + { + "title": { + "text": "Financial Information", + "class": "section-header" + }, + "class": "financial-section", + "fields": [ + "financial.annualIncome", + "financial.creditScore", + "financial.accountType" + ] + }, + { + "title": { + "text": "Skills & Portfolio", + "class": "section-header" + }, + "class": "skills-section", + "fields": [ + "skills", + "portfolio" + ] + }, + { + "title": { + "text": "Final Details", + "class": "section-header" + }, + "class": "final-section", + "fields": [ + "comments", + "termsAccepted", + "privacyAccepted" + ] + } + ], + "submit": { + "label": "Submit Application", + "class": "btn btn-primary btn-lg", + "id": "submit-btn" + }, + "reset": { + "label": "Reset Form", + "class": "btn btn-secondary", + "id": "reset-btn" + }, + "buttons": [ + { + "type": "button", + "label": "Save Draft", + "class": "btn btn-outline-secondary", + "id": "save-draft-btn", + "onclick": "saveDraft()" + }, + { + "type": "button", + "label": "Preview", + "class": "btn btn-outline-primary", + "id": "preview-btn", + "onclick": "showPreview()" + } + ] + }, + "examples": [ + { + "personalInfo": { + "firstName": "John", + "lastName": "Doe", + "age": 30, + "email": "john.doe@example.com", + "phone": "+1-555-123-4567", + "dateOfBirth": "1993-05-15", + "website": "https://johndoe.com" + }, + "address": { + "street": "123 Main St", + "city": "New York", + "state": "NY", + "country": "US", + "postalCode": "10001" + }, + "preferences": { + "newsletter": true, + "notifications": [ "email", "push" ], + "theme": "dark", + "language": "en" + }, + "financial": { + "annualIncome": 75000, + "creditScore": 750, + "accountType": "premium", + "paymentMethod": "credit_card" + }, + "termsAccepted": true, + "privacyAccepted": true + } + ] +} diff --git a/examples/app/validation-features.json b/examples/app/validation-features.json new file mode 100644 index 0000000..81dcc9e --- /dev/null +++ b/examples/app/validation-features.json @@ -0,0 +1,586 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "Advanced Validation Features Demo", + "description": "Demonstrates all implemented validation features", + "properties": { + "stringValidations": { + "type": "object", + "title": "String Validation Examples", + "properties": { + "basicString": { + "type": "string", + "title": "Basic String with Length Limits", + "minLength": 3, + "maxLength": 20, + "ui": { + "element": "input", + "type": "text", + "class": "form-group", + "order": 1 + } + }, + "patternString": { + "type": "string", + "title": "Pattern Validation (Letters Only)", + "pattern": "^[A-Za-z\\s]+$", + "ui": { + "element": "input", + "type": "text", + "class": "form-group", + "order": 2, + "placeholder": "Letters and spaces only" + } + }, + "emailField": { + "type": "string", + "format": "email", + "title": "Email Format Validation", + "ui": { + "element": "input", + "type": "email", + "class": "form-group", + "order": 3 + } + }, + "urlField": { + "type": "string", + "format": "uri", + "title": "URL Format Validation", + "ui": { + "element": "input", + "type": "url", + "class": "form-group", + "order": 4 + } + }, + "dateField": { + "type": "string", + "format": "date", + "title": "Date Format Validation", + "ui": { + "element": "input", + "type": "date", + "class": "form-group", + "order": 5 + } + }, + "enumField": { + "type": "string", + "title": "Enum Selection", + "enum": [ "option1", "option2", "option3" ], + "ui": { + "element": "select", + "class": "form-group", + "order": 6, + "options": [ + { "value": "option1", "text": "Option 1" }, + { "value": "option2", "text": "Option 2" }, + { "value": "option3", "text": "Option 3" } + ] + } + } + }, + "required": [ "basicString", "emailField" ] + }, + "numericValidations": { + "type": "object", + "title": "Numeric Validation Examples", + "properties": { + "integerField": { + "type": "integer", + "title": "Integer with Range", + "minimum": 1, + "maximum": 100, + "ui": { + "element": "input", + "type": "number", + "class": "form-group", + "order": 1 + } + }, + "numberField": { + "type": "number", + "title": "Number with Exclusive Range", + "exclusiveMinimum": 0, + "exclusiveMaximum": 1000, + "multipleOf": 0.5, + "ui": { + "element": "input", + "type": "number", + "class": "form-group", + "order": 2, + "step": "0.5" + } + }, + "rangeField": { + "type": "integer", + "title": "Range Slider", + "minimum": 0, + "maximum": 100, + "default": 50, + "ui": { + "element": "input", + "type": "range", + "class": "form-group", + "order": 3 + } + } + } + }, + "arrayValidations": { + "type": "object", + "title": "Array Validation Examples", + "properties": { + "multiSelect": { + "type": "array", + "title": "Multi-Select with Constraints", + "minItems": 2, + "maxItems": 4, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ "red", "green", "blue", "yellow", "purple", "orange" ] + }, + "ui": { + "element": "select", + "class": "form-group", + "order": 1, + "multiple": true, + "options": [ + { "value": "red", "text": "Red" }, + { "value": "green", "text": "Green" }, + { "value": "blue", "text": "Blue" }, + { "value": "yellow", "text": "Yellow" }, + { "value": "purple", "text": "Purple" }, + { "value": "orange", "text": "Orange" } + ] + } + }, + "dynamicList": { + "type": "array", + "title": "Dynamic List of Objects", + "minItems": 1, + "maxItems": 5, + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Name", + "minLength": 2 + }, + "value": { + "type": "number", + "title": "Value", + "minimum": 0 + } + }, + "required": [ "name" ] + }, + "ui": { + "element": "fieldset", + "class": "dynamic-list", + "order": 2 + } + } + } + }, + "conditionalValidations": { + "type": "object", + "title": "Conditional Validation Examples", + "properties": { + "userType": { + "type": "string", + "title": "User Type", + "enum": [ "individual", "business", "non-profit" ], + "ui": { + "element": "select", + "class": "form-group", + "order": 1, + "options": [ + { "value": "individual", "text": "Individual" }, + { "value": "business", "text": "Business" }, + { "value": "non-profit", "text": "Non-Profit" } + ] + } + }, + "individualInfo": { + "type": "object", + "title": "Individual Information", + "properties": { + "ssn": { + "type": "string", + "title": "Social Security Number", + "pattern": "^\\d{3}-\\d{2}-\\d{4}$", + "ui": { + "element": "input", + "type": "text", + "class": "form-group", + "placeholder": "XXX-XX-XXXX" + } + } + } + }, + "businessInfo": { + "type": "object", + "title": "Business Information", + "properties": { + "taxId": { + "type": "string", + "title": "Tax ID", + "pattern": "^\\d{2}-\\d{7}$", + "ui": { + "element": "input", + "type": "text", + "class": "form-group", + "placeholder": "XX-XXXXXXX" + } + }, + "businessName": { + "type": "string", + "title": "Business Name", + "minLength": 2, + "ui": { + "element": "input", + "type": "text", + "class": "form-group" + } + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "userType": { "const": "individual" } + } + }, + "then": { + "properties": { + "individualInfo": { + "required": [ "ssn" ] + } + } + } + }, + { + "if": { + "properties": { + "userType": { "const": "business" } + } + }, + "then": { + "properties": { + "businessInfo": { + "required": [ "taxId", "businessName" ] + } + } + } + } + ] + }, + "dependentFields": { + "type": "object", + "title": "Dependent Field Examples", + "properties": { + "hasPhone": { + "type": "boolean", + "title": "I have a phone number", + "ui": { + "element": "input", + "type": "checkbox", + "class": "form-group", + "order": 1 + } + }, + "phoneNumber": { + "type": "string", + "title": "Phone Number", + "pattern": "^\\+?[1-9]\\d{1,14}$", + "ui": { + "element": "input", + "type": "tel", + "class": "form-group", + "order": 2 + } + }, + "preferredContact": { + "type": "string", + "title": "Preferred Contact Method", + "enum": [ "email", "phone", "mail" ], + "ui": { + "element": "select", + "class": "form-group", + "order": 3, + "options": [ + { "value": "email", "text": "Email" }, + { "value": "phone", "text": "Phone" }, + { "value": "mail", "text": "Physical Mail" } + ] + } + }, + "mailingAddress": { + "type": "string", + "title": "Mailing Address", + "ui": { + "element": "textarea", + "class": "form-group", + "order": 4, + "rows": 3 + } + } + }, + "dependentRequired": { + "hasPhone": [ "phoneNumber" ], + "preferredContact": [ "preferredContact" ] + }, + "if": { + "properties": { + "preferredContact": { "const": "phone" } + } + }, + "then": { + "required": [ "phoneNumber" ] + }, + "else": { + "if": { + "properties": { + "preferredContact": { "const": "mail" } + } + }, + "then": { + "required": [ "mailingAddress" ] + } + } + }, + "compositionValidations": { + "type": "object", + "title": "Composition Validation Examples (allOf, anyOf, oneOf)", + "properties": { + "contactMethod": { + "title": "Contact Method Validation", + "anyOf": [ + { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email Address" + } + }, + "required": [ "email" ] + }, + { + "type": "object", + "properties": { + "phone": { + "type": "string", + "pattern": "^\\+?[1-9]\\d{1,14}$", + "title": "Phone Number" + } + }, + "required": [ "phone" ] + } + ], + "ui": { + "element": "fieldset", + "class": "contact-methods", + "order": 1 + } + }, + "paymentMethod": { + "title": "Payment Method (One Of)", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { "const": "credit_card" }, + "cardNumber": { + "type": "string", + "pattern": "^\\d{16}$", + "title": "Card Number" + }, + "expiryDate": { + "type": "string", + "pattern": "^(0[1-9]|1[0-2])\\/\\d{2}$", + "title": "Expiry Date (MM/YY)" + } + }, + "required": [ "type", "cardNumber", "expiryDate" ] + }, + { + "type": "object", + "properties": { + "type": { "const": "paypal" }, + "paypalEmail": { + "type": "string", + "format": "email", + "title": "PayPal Email" + } + }, + "required": [ "type", "paypalEmail" ] + } + ], + "ui": { + "element": "fieldset", + "class": "payment-methods", + "order": 2 + } + } + } + }, + "advancedFeatures": { + "type": "object", + "title": "Advanced Features", + "properties": { + "constField": { + "const": "REQUIRED_VALUE", + "title": "Constant Value Field", + "ui": { + "element": "input", + "type": "hidden", + "value": "REQUIRED_VALUE" + } + }, + "booleanField": { + "type": "boolean", + "title": "Boolean Checkbox", + "ui": { + "element": "input", + "type": "checkbox", + "class": "form-group" + } + }, + "readOnlyField": { + "type": "string", + "title": "Read Only Field", + "default": "This is read only", + "readOnly": true, + "ui": { + "element": "input", + "type": "text", + "class": "form-group", + "readonly": true + } + }, + "textareaField": { + "type": "string", + "title": "Large Text Area", + "maxLength": 500, + "ui": { + "element": "textarea", + "class": "form-group", + "rows": 4, + "placeholder": "Enter detailed information here..." + } + } + } + } + }, + "required": [ "stringValidations" ], + "form": { + "class": "validation-demo-form", + "action": "/api/validate-form", + "method": "POST", + "groups": [ + { + "title": { + "text": "String Validations", + "class": "section-header" + }, + "class": "string-validation-section", + "fields": [ + "stringValidations.basicString", + "stringValidations.patternString", + "stringValidations.emailField", + "stringValidations.urlField", + "stringValidations.dateField", + "stringValidations.enumField" + ] + }, + { + "title": { + "text": "Numeric Validations", + "class": "section-header" + }, + "class": "numeric-validation-section", + "fields": [ + "numericValidations.integerField", + "numericValidations.numberField", + "numericValidations.rangeField" + ] + }, + { + "title": { + "text": "Array Validations", + "class": "section-header" + }, + "class": "array-validation-section", + "fields": [ + "arrayValidations.multiSelect", + "arrayValidations.dynamicList" + ] + }, + { + "title": { + "text": "Conditional Validations", + "class": "section-header" + }, + "class": "conditional-validation-section", + "fields": [ + "conditionalValidations.userType", + "conditionalValidations.individualInfo", + "conditionalValidations.businessInfo" + ] + }, + { + "title": { + "text": "Dependent Fields", + "class": "section-header" + }, + "class": "dependent-validation-section", + "fields": [ + "dependentFields.hasPhone", + "dependentFields.phoneNumber", + "dependentFields.preferredContact", + "dependentFields.mailingAddress" + ] + }, + { + "title": { + "text": "Composition Validations", + "class": "section-header" + }, + "class": "composition-validation-section", + "fields": [ + "compositionValidations.contactMethod", + "compositionValidations.paymentMethod" + ] + }, + { + "title": { + "text": "Advanced Features", + "class": "section-header" + }, + "class": "advanced-features-section", + "fields": [ + "advancedFeatures.constField", + "advancedFeatures.booleanField", + "advancedFeatures.readOnlyField", + "advancedFeatures.textareaField" + ] + } + ], + "submit": { + "label": "Validate Form", + "class": "btn btn-primary" + }, + "reset": { + "label": "Reset All", + "class": "btn btn-secondary" + } + } +} diff --git a/examples/validation-demo/README.md b/examples/validation-demo/README.md new file mode 100644 index 0000000..40ad82f --- /dev/null +++ b/examples/validation-demo/README.md @@ -0,0 +1,103 @@ +# JSON Schema Validation Examples + +This directory contains comprehensive examples demonstrating all the validation features implemented in the JSON Schema form builder. + +## Files + +### Schema Examples + +1. **`comprehensive-validation.json`** - Complete example showcasing all JSON Schema 2020-12 validation features: + - Personal information with string validations (minLength, maxLength, pattern, format) + - Address with conditional validations based on country + - Preferences with array validations and enum constraints + - Financial information with numeric validations and conditional requirements + - Skills array with object validation + - Portfolio with anyOf compositions + - Terms acceptance with const validation + +2. **`validation-features.json`** - Organized examples by validation category: + - **String Validations**: Basic length limits, pattern matching, format validation (email, URL, date), enum selection + - **Numeric Validations**: Integer ranges, number with exclusive bounds, multipleOf, range sliders + - **Array Validations**: Multi-select with constraints, dynamic object arrays + - **Conditional Validations**: if/then/else logic based on user type selection + - **Dependent Fields**: dependentRequired, conditional field requirements + - **Composition Validations**: anyOf for flexible contact methods, oneOf for exclusive payment methods + - **Advanced Features**: const fields, boolean checkboxes, read-only fields, textarea with maxLength + +3. **`complex.json`** - Original company registration form with nested objects and grouped fields + +### Demo Server + +**`main.go`** - HTTP server that renders the schema examples into interactive HTML forms with: +- Real-time client-side validation +- HTML5 form controls with validation attributes +- Tailwind CSS styling +- JavaScript validation feedback +- Form submission handling + +## Validation Features Demonstrated + +### String Validations +- `minLength` / `maxLength` - Length constraints +- `pattern` - Regular expression validation +- `format` - Built-in formats (email, uri, date, etc.) +- `enum` - Predefined value lists + +### Numeric Validations +- `minimum` / `maximum` - Inclusive bounds +- `exclusiveMinimum` / `exclusiveMaximum` - Exclusive bounds +- `multipleOf` - Value must be multiple of specified number +- Integer vs Number types + +### Array Validations +- `minItems` / `maxItems` - Size constraints +- `uniqueItems` - No duplicate values +- `items` - Schema for array elements +- Multi-select form controls + +### Object Validations +- `required` - Required properties +- `dependentRequired` - Fields required based on other fields +- `dependentSchemas` - Schema changes based on other fields +- Nested object structures + +### Conditional Logic +- `if` / `then` / `else` - Conditional schema application +- `allOf` - Must satisfy all sub-schemas +- `anyOf` - Must satisfy at least one sub-schema +- `oneOf` - Must satisfy exactly one sub-schema + +### Advanced Features +- `const` - Constant values +- `default` - Default values +- `readOnly` - Read-only fields +- Boolean validation +- HTML form grouping and styling + +## Running the Demo + +1. Navigate to the validation-demo directory: + ```bash + cd examples/validation-demo + ``` + +2. Run the server: + ```bash + go run main.go + ``` + +3. Open your browser to `http://localhost:8080` + +4. Explore the different validation examples: + - Comprehensive Validation Demo - Complete real-world form + - Validation Features Demo - Organized by validation type + - Complex Form Demo - Original nested object example + +## Implementation Details + +The validation system is implemented in: +- `/renderer/validation.go` - Core validation logic and HTML attribute generation +- `/renderer/schema.go` - Form rendering with validation integration +- Client-side JavaScript - Real-time validation feedback + +All examples demonstrate both server-side schema validation and client-side HTML5 validation attributes working together. diff --git a/examples/validation-demo/main.go b/examples/validation-demo/main.go new file mode 100644 index 0000000..e5dea08 --- /dev/null +++ b/examples/validation-demo/main.go @@ -0,0 +1,173 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + + "github.com/oarkflow/jsonschema" + "github.com/oarkflow/mq/renderer" +) + +func main() { + // Setup routes + http.HandleFunc("/", indexHandler) + http.HandleFunc("/comprehensive", func(w http.ResponseWriter, r *http.Request) { + renderFormHandler(w, r, "comprehensive-validation.json", "Comprehensive Validation Demo") + }) + http.HandleFunc("/features", func(w http.ResponseWriter, r *http.Request) { + renderFormHandler(w, r, "validation-features.json", "Validation Features Demo") + }) + http.HandleFunc("/complex", func(w http.ResponseWriter, r *http.Request) { + renderFormHandler(w, r, "complex.json", "Complex Form Demo") + }) + + fmt.Println("Server starting on :8080") + fmt.Println("Available examples:") + fmt.Println(" - http://localhost:8080/comprehensive - Comprehensive validation demo") + fmt.Println(" - http://localhost:8080/features - Validation features demo") + fmt.Println(" - http://localhost:8080/complex - Complex form demo") + + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +func renderFormHandler(w http.ResponseWriter, r *http.Request, schemaFile, title string) { + // Load and compile schema + schemaData, err := os.ReadFile(schemaFile) + if err != nil { + http.Error(w, fmt.Sprintf("Error reading schema file: %v", err), http.StatusInternalServerError) + return + } + + // Parse JSON schema + var schemaMap map[string]interface{} + if err := json.Unmarshal(schemaData, &schemaMap); err != nil { + http.Error(w, fmt.Sprintf("Error parsing JSON schema: %v", err), http.StatusInternalServerError) + return + } + + // Compile schema + compiler := jsonschema.NewCompiler() + schema, err := compiler.Compile(schemaData) + if err != nil { + http.Error(w, fmt.Sprintf("Error compiling schema: %v", err), http.StatusInternalServerError) + return + } + + // Create renderer with basic template + basicTemplate := ` +
+ {{.FieldsHTML}} +
+ {{.ButtonsHTML}} +
+
+ ` + + renderer := renderer.NewJSONSchemaRenderer(schema, basicTemplate) + + // Render form with empty data + html, err := renderer.RenderFields(map[string]any{}) + if err != nil { + http.Error(w, fmt.Sprintf("Error rendering form: %v", err), http.StatusInternalServerError) + return + } + + // Create complete HTML page + fullHTML := createFullHTMLPage(title, html) + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(fullHTML)) +} + +func indexHandler(w http.ResponseWriter, r *http.Request) { + html := "" + + "" + + "" + + "JSON Schema Form Builder - Validation Examples" + + "" + + "" + + "" + + "

JSON Schema Form Builder - Validation Examples

" + + "

This demo showcases comprehensive JSON Schema 2020-12 validation support with:

" + + "" + + "

Available Examples:

" + + "" + + "" + + "" + + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(html)) +} + +func createFullHTMLPage(title, formHTML string) string { + template := "" + + "" + + "" + + "" + title + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "
" + + "← Back to Examples" + + "
" + + "

" + title + "

" + + "

This form demonstrates comprehensive JSON Schema validation.

" + + formHTML + + "
" + + "" + + "" + + return template +} diff --git a/renderer/schema.go b/renderer/schema.go index 914a024..62930c2 100644 --- a/renderer/schema.go +++ b/renderer/schema.go @@ -139,6 +139,32 @@ var voidElements = map[string]bool{ "param": true, "source": true, "track": true, "wbr": true, } +// Template cache for compiled templates +var ( + compiledFieldTemplates = make(map[string]*template.Template) + compiledGroupTemplate *template.Template + templateCacheMutex sync.RWMutex +) + +// Initialize compiled templates once +func init() { + var err error + compiledGroupTemplate, err = template.New("group").Parse(groupTemplateStr) + if err != nil { + panic(fmt.Sprintf("Failed to compile group template: %v", err)) + } + + templateCacheMutex.Lock() + defer templateCacheMutex.Unlock() + + for element, tmplStr := range fieldTemplates { + compiled, err := template.New(element).Parse(tmplStr) + if err == nil { + compiledFieldTemplates[element] = compiled + } + } +} + var standardAttrs = []string{ "id", "class", "name", "type", "value", "placeholder", "href", "src", "alt", "title", "target", "rel", "role", "tabindex", "accesskey", @@ -160,6 +186,7 @@ type FieldInfo struct { Order int Schema *jsonschema.Schema IsRequired bool + Validation ValidationInfo // Add validation information } // GroupInfo represents metadata for a group extracted from JSONSchema @@ -177,17 +204,79 @@ type GroupTitle struct { // JSONSchemaRenderer is responsible for rendering HTML fields based on JSONSchema type JSONSchemaRenderer struct { - Schema *jsonschema.Schema - HTMLLayout string - cachedHTML string // Cached rendered HTML + Schema *jsonschema.Schema + HTMLLayout string + compiledLayout *template.Template + cachedGroups []GroupInfo + cachedButtons string + formConfig FormConfig + cacheMutex sync.RWMutex +} + +// FormConfig holds cached form configuration +type FormConfig struct { + Class string + Action string + Method string + Enctype string } // NewJSONSchemaRenderer creates a new instance of JSONSchemaRenderer func NewJSONSchemaRenderer(schema *jsonschema.Schema, htmlLayout string) *JSONSchemaRenderer { - return &JSONSchemaRenderer{ + renderer := &JSONSchemaRenderer{ Schema: schema, HTMLLayout: htmlLayout, } + + // Pre-compile layout template + renderer.compileLayoutTemplate() + + // Pre-parse and cache groups and form config + renderer.precomputeStaticData() + + return renderer +} + +// compileLayoutTemplate pre-compiles the layout template +func (r *JSONSchemaRenderer) compileLayoutTemplate() { + tmpl, err := template.New("layout").Funcs(template.FuncMap{ + "form_groups": func(groupsHTML string) template.HTML { + return template.HTML(groupsHTML) + }, + "form_buttons": func() template.HTML { + return template.HTML(r.cachedButtons) + }, + "form_attributes": func(formAction string) template.HTMLAttr { + return template.HTMLAttr(fmt.Sprintf(`class="%s" action="%s" method="%s" enctype="%s"`, + r.formConfig.Class, formAction, r.formConfig.Method, r.formConfig.Enctype)) + }, + }).Parse(r.HTMLLayout) + + if err == nil { + r.compiledLayout = tmpl + } +} + +// precomputeStaticData caches groups and form configuration +func (r *JSONSchemaRenderer) precomputeStaticData() { + r.cachedGroups = r.parseGroupsFromSchema() + r.cachedButtons = r.renderButtons() + + // Cache form configuration + if r.Schema.Form != nil { + if class, ok := r.Schema.Form["class"].(string); ok { + r.formConfig.Class = class + } + if action, ok := r.Schema.Form["action"].(string); ok { + r.formConfig.Action = action + } + if method, ok := r.Schema.Form["method"].(string); ok { + r.formConfig.Method = method + } + if enctype, ok := r.Schema.Form["enctype"].(string); ok { + r.formConfig.Enctype = enctype + } + } } // interpolateTemplate replaces template placeholders with actual values @@ -220,35 +309,65 @@ func (r *JSONSchemaRenderer) interpolateTemplate(templateStr string, data map[st // RenderFields generates HTML for fields based on the JSONSchema func (r *JSONSchemaRenderer) RenderFields(data map[string]any) (string, error) { - // Return cached HTML if available - if r.cachedHTML != "" { - return r.cachedHTML, nil + r.cacheMutex.RLock() + defer r.cacheMutex.RUnlock() + + // Use a string builder for efficient string concatenation + var groupHTML strings.Builder + groupHTML.Grow(1024) // Pre-allocate reasonable capacity + + for _, group := range r.cachedGroups { + groupHTML.WriteString(renderGroupOptimized(group)) } - groups := r.parseGroupsFromSchema() - var groupHTML bytes.Buffer - for _, group := range groups { - groupHTML.WriteString(renderGroup(group)) + // Interpolate dynamic form action + formAction := r.interpolateTemplate(r.formConfig.Action, data) + + // Use pre-compiled template if available + if r.compiledLayout != nil { + var renderedHTML bytes.Buffer + + templateData := struct { + GroupsHTML string + FormAction string + }{ + GroupsHTML: groupHTML.String(), + FormAction: formAction, + } + + // Update the template functions with current data + updatedTemplate := r.compiledLayout.Funcs(template.FuncMap{ + "form_groups": func() template.HTML { + return template.HTML(templateData.GroupsHTML) + }, + "form_buttons": func() template.HTML { + return template.HTML(r.cachedButtons) + }, + "form_attributes": func() template.HTMLAttr { + return template.HTMLAttr(fmt.Sprintf(`class="%s" action="%s" method="%s" enctype="%s"`, + r.formConfig.Class, templateData.FormAction, r.formConfig.Method, r.formConfig.Enctype)) + }, + }) + + if err := updatedTemplate.Execute(&renderedHTML, nil); err != nil { + return "", fmt.Errorf("failed to execute compiled template: %w", err) + } + + return renderedHTML.String(), nil } - // Extract form configuration - var formClass, formAction, formMethod, formEnctype string - if r.Schema.Form != nil { - if class, ok := r.Schema.Form["class"].(string); ok { - formClass = class - } - if action, ok := r.Schema.Form["action"].(string); ok { - formAction = r.interpolateTemplate(action, data) - } - if method, ok := r.Schema.Form["method"].(string); ok { - formMethod = method - } - if enctype, ok := r.Schema.Form["enctype"].(string); ok { - formEnctype = enctype - } + // Fallback to original method if compilation failed + return r.renderFieldsFallback(data) +} + +// renderFieldsFallback provides fallback rendering when template compilation fails +func (r *JSONSchemaRenderer) renderFieldsFallback(data map[string]any) (string, error) { + var groupHTML strings.Builder + for _, group := range r.cachedGroups { + groupHTML.WriteString(renderGroupOptimized(group)) } - buttonsHTML := r.renderButtons() + formAction := r.interpolateTemplate(r.formConfig.Action, data) // Create a new template with the layout and functions tmpl, err := template.New("layout").Funcs(template.FuncMap{ @@ -256,11 +375,11 @@ func (r *JSONSchemaRenderer) RenderFields(data map[string]any) (string, error) { return template.HTML(groupHTML.String()) }, "form_buttons": func() template.HTML { - return template.HTML(buttonsHTML) + return template.HTML(r.cachedButtons) }, "form_attributes": func() template.HTMLAttr { return template.HTMLAttr(fmt.Sprintf(`class="%s" action="%s" method="%s" enctype="%s"`, - formClass, formAction, formMethod, formEnctype)) + r.formConfig.Class, formAction, r.formConfig.Method, r.formConfig.Enctype)) }, }).Parse(r.HTMLLayout) if err != nil { @@ -272,8 +391,7 @@ func (r *JSONSchemaRenderer) RenderFields(data map[string]any) (string, error) { return "", fmt.Errorf("failed to execute HTML template: %w", err) } - r.cachedHTML = renderedHTML.String() - return r.cachedHTML, nil + return renderedHTML.String(), nil } // parseGroupsFromSchema extracts and sorts groups and fields from schema @@ -386,6 +504,7 @@ func (r *JSONSchemaRenderer) extractFieldsFromPath(fieldPath, parentPath string) Order: order, Schema: schema, IsRequired: isRequired, + Validation: extractValidationInfo(schema, isRequired), }) } @@ -423,6 +542,7 @@ func (r *JSONSchemaRenderer) extractFieldsFromNestedSchema(propName, parentPath Order: order, Schema: propSchema, IsRequired: isRequired, + Validation: extractValidationInfo(propSchema, isRequired), }) } @@ -481,18 +601,58 @@ func renderGroup(group GroupInfo) string { return groupHTML.String() } +// renderGroupOptimized uses pre-compiled templates and string builder for better performance +func renderGroupOptimized(group GroupInfo) string { + // Collect all validations for dependency checking + allValidations := make(map[string]ValidationInfo) + for _, field := range group.Fields { + allValidations[field.FieldPath] = field.Validation + } + + // Use string builder for better performance + var fieldsHTML strings.Builder + fieldsHTML.Grow(512) // Pre-allocate reasonable capacity + + for _, field := range group.Fields { + fieldsHTML.WriteString(renderFieldWithContext(field, allValidations)) + } + + // Use pre-compiled group template + templateCacheMutex.RLock() + groupTemplate := compiledGroupTemplate + templateCacheMutex.RUnlock() + + if groupTemplate != nil { + var groupHTML bytes.Buffer + data := map[string]any{ + "Title": group.Title, + "GroupClass": group.GroupClass, + "FieldsHTML": template.HTML(fieldsHTML.String()), + } + + if err := groupTemplate.Execute(&groupHTML, data); err == nil { + return groupHTML.String() + } + } + + // Fallback to original method + return renderGroup(group) +} + func renderField(field FieldInfo) string { - if field.Schema.UI == nil { + return renderFieldWithContext(field, make(map[string]ValidationInfo)) +} + +// renderFieldWithContext renders a field with access to all field validations for dependency checking +func renderFieldWithContext(field FieldInfo, allValidations map[string]ValidationInfo) string { + // Determine the appropriate element type based on schema + element := determineFieldElement(field.Schema) + if element == "" { return "" } - element, ok := field.Schema.UI["element"].(string) - if !ok || element == "" { - return "" - } - - // Build all attributes - allAttributes := buildAllAttributes(field) + // Build all attributes including validation + allAttributes := buildAllAttributesWithValidation(field) // Get content content := getFieldContent(field) @@ -501,14 +661,20 @@ func renderField(field FieldInfo) string { // Generate label if needed labelHTML := generateLabel(field) + // Generate options for select/radio elements + optionsHTML := generateOptionsFromSchema(field.Schema) + + // Generate client-side validation with context + validationJS := generateClientSideValidation(field.FieldPath, field.Validation, allValidations) + data := map[string]any{ "Element": element, "AllAttributes": template.HTMLAttr(allAttributes), "Content": content, - "ContentHTML": template.HTML(contentHTML), + "ContentHTML": template.HTML(contentHTML + validationJS), "LabelHTML": template.HTML(labelHTML), - "Class": getUIValue(field.Schema.UI, "class"), - "OptionsHTML": template.HTML(generateOptions(field.Schema.UI)), + "Class": getFieldWrapperClass(field.Schema), + "OptionsHTML": template.HTML(optionsHTML), } // Use specific template if available, otherwise use generic @@ -529,6 +695,212 @@ func renderField(field FieldInfo) string { return buf.String() } +// determineFieldElement determines the appropriate HTML element based on JSON Schema +func determineFieldElement(schema *jsonschema.Schema) string { + // Check UI element first + if schema.UI != nil { + if element, ok := schema.UI["element"].(string); ok { + return element + } + } + + // Auto-determine based on schema properties + if shouldUseSelect(schema) { + return "select" + } + + if shouldUseTextarea(schema) { + return "textarea" + } + + // Default to input for most cases + var typeStr string + if len(schema.Type) > 0 { + typeStr = schema.Type[0] + } + + switch typeStr { + case "boolean": + return "input" // will be type="checkbox" + case "array": + return "input" // could be enhanced for array inputs + case "object": + return "fieldset" // for nested objects + default: + return "input" + } +} + +// buildAllAttributesWithValidation creates all HTML attributes including validation +func buildAllAttributesWithValidation(field FieldInfo) string { + var builder strings.Builder + builder.Grow(512) // Pre-allocate capacity + + // Use the field path as the name attribute for nested fields + fieldName := field.FieldPath + if fieldName == "" { + fieldName = field.Name + } + + // Add name attribute + builder.WriteString(`name="`) + builder.WriteString(fieldName) + builder.WriteString(`"`) + + // Add id attribute for accessibility + fieldId := strings.ReplaceAll(fieldName, ".", "_") + builder.WriteString(` id="`) + builder.WriteString(fieldId) + builder.WriteString(`"`) + + // Determine and add type attribute for input elements + element := determineFieldElement(field.Schema) + if element == "input" { + inputType := getInputTypeFromSchema(field.Schema) + builder.WriteString(` type="`) + builder.WriteString(inputType) + builder.WriteString(`"`) + } + + // Add validation attributes + validationAttrs := generateValidationAttributes(field.Validation) + for _, attr := range validationAttrs { + builder.WriteString(` `) + builder.WriteString(attr) + } + + // Add default value + if defaultValue := getDefaultValue(field.Schema); defaultValue != "" { + builder.WriteString(` value="`) + builder.WriteString(defaultValue) + builder.WriteString(`"`) + } + + // Add placeholder + if placeholder := getPlaceholder(field.Schema); placeholder != "" { + builder.WriteString(` placeholder="`) + builder.WriteString(placeholder) + builder.WriteString(`"`) + } + + // Add standard attributes from UI + if field.Schema.UI != nil { + for _, attr := range standardAttrs { + if attr == "name" || attr == "id" || attr == "type" || attr == "required" || + attr == "pattern" || attr == "min" || attr == "max" || attr == "minlength" || + attr == "maxlength" || attr == "step" || attr == "value" || attr == "placeholder" { + continue // Already handled above or in validation + } + if value, exists := field.Schema.UI[attr]; exists { + if attr == "class" && value == "" { + continue // Skip empty class + } + builder.WriteString(` `) + builder.WriteString(attr) + builder.WriteString(`="`) + builder.WriteString(fmt.Sprintf("%v", value)) + builder.WriteString(`"`) + } + } + + // Add data-* and aria-* attributes + for key, value := range field.Schema.UI { + if strings.HasPrefix(key, "data-") || strings.HasPrefix(key, "aria-") { + builder.WriteString(` `) + builder.WriteString(key) + builder.WriteString(`="`) + builder.WriteString(fmt.Sprintf("%v", value)) + builder.WriteString(`"`) + } + } + } + + return builder.String() +} + +// generateOptionsFromSchema generates option HTML from schema enum or UI options +func generateOptionsFromSchema(schema *jsonschema.Schema) string { + var optionsHTML strings.Builder + + // Check UI options first + if schema.UI != nil { + if options, ok := schema.UI["options"].([]interface{}); ok { + for _, option := range options { + if optionMap, ok := option.(map[string]interface{}); ok { + value := getMapValue(optionMap, "value", "") + text := getMapValue(optionMap, "text", value) + selected := "" + if isSelected, ok := optionMap["selected"].(bool); ok && isSelected { + selected = ` selected="selected"` + } + disabled := "" + if isDisabled, ok := optionMap["disabled"].(bool); ok && isDisabled { + disabled = ` disabled="disabled"` + } + optionsHTML.WriteString(fmt.Sprintf(``, + value, selected, disabled, text)) + } else { + optionsHTML.WriteString(fmt.Sprintf(``, option, option)) + } + } + return optionsHTML.String() + } + } + + // Generate options from enum + if len(schema.Enum) > 0 { + for _, enumValue := range schema.Enum { + optionsHTML.WriteString(fmt.Sprintf(``, enumValue, enumValue)) + } + } + + return optionsHTML.String() +} + +// getFieldWrapperClass determines the CSS class for the field wrapper +func getFieldWrapperClass(schema *jsonschema.Schema) string { + if schema.UI != nil { + if class, ok := schema.UI["class"].(string); ok { + return class + } + } + return "form-group" // default class +} + +// getDefaultValue extracts default value from schema +func getDefaultValue(schema *jsonschema.Schema) string { + if schema.Default != nil { + return fmt.Sprintf("%v", schema.Default) + } + return "" +} + +// getPlaceholder extracts placeholder from schema +func getPlaceholder(schema *jsonschema.Schema) string { + if schema.UI != nil { + if placeholder, ok := schema.UI["placeholder"].(string); ok { + return placeholder + } + } + + // Auto-generate placeholder from title or description + if schema.Title != nil { + return fmt.Sprintf("Enter %s", strings.ToLower(*schema.Title)) + } + + if schema.Description != nil { + return *schema.Description + } + + return "" +} + +// renderFieldOptimized uses pre-compiled templates and optimized attribute building +func renderFieldOptimized(field FieldInfo) string { + // Use the new comprehensive rendering logic + return renderField(field) +} + func buildAllAttributes(field FieldInfo) string { var attributes []string @@ -593,6 +965,88 @@ func buildAllAttributes(field FieldInfo) string { return strings.Join(attributes, " ") } +// buildAllAttributesOptimized uses string builder for better performance +func buildAllAttributesOptimized(field FieldInfo) string { + var builder strings.Builder + builder.Grow(256) // Pre-allocate reasonable capacity + + // Use the field path as the name attribute for nested fields + fieldName := field.FieldPath + if fieldName == "" { + fieldName = field.Name + } + + // Add name attribute + builder.WriteString(`name="`) + builder.WriteString(fieldName) + builder.WriteString(`"`) + + // Add standard attributes from UI + if field.Schema.UI != nil { + element, _ := field.Schema.UI["element"].(string) + for _, attr := range standardAttrs { + if attr == "name" { + continue // Already handled above + } + if value, exists := field.Schema.UI[attr]; exists { + if attr == "class" && value == "" { + continue // Skip empty class + } + // For select, input, textarea, add class to element itself + if attr == "class" && (element == "select" || element == "input" || element == "textarea") { + builder.WriteString(` class="`) + builder.WriteString(fmt.Sprintf("%v", value)) + builder.WriteString(`"`) + continue + } + // For other elements, do not add class here (it will be handled in the wrapper div) + if attr == "class" { + continue + } + builder.WriteString(` `) + builder.WriteString(attr) + builder.WriteString(`="`) + builder.WriteString(fmt.Sprintf("%v", value)) + builder.WriteString(`"`) + } + } + + // Add data-* and aria-* attributes + for key, value := range field.Schema.UI { + if strings.HasPrefix(key, "data-") || strings.HasPrefix(key, "aria-") { + builder.WriteString(` `) + builder.WriteString(key) + builder.WriteString(`="`) + builder.WriteString(fmt.Sprintf("%v", value)) + builder.WriteString(`"`) + } + } + } + + // Handle required field + if field.IsRequired { + currentAttrs := builder.String() + if !strings.Contains(currentAttrs, "required=") { + builder.WriteString(` required="required"`) + } + } + + // Handle input type based on field type + element, _ := field.Schema.UI["element"].(string) + if element == "input" { + if inputType := getInputType(field.Schema); inputType != "" { + currentAttrs := builder.String() + if !strings.Contains(currentAttrs, "type=") { + builder.WriteString(` type="`) + builder.WriteString(inputType) + builder.WriteString(`"`) + } + } + } + + return builder.String() +} + func getInputType(schema *jsonschema.Schema) string { // Check UI type first if schema.UI != nil { @@ -835,14 +1289,42 @@ type RequestSchemaTemplate struct { Renderer *JSONSchemaRenderer `json:"template"` } -var cache = make(map[string]*RequestSchemaTemplate) -var mu = &sync.Mutex{} -var BaseTemplateDir = "templates" +var ( + cache = make(map[string]*RequestSchemaTemplate) + mu = &sync.RWMutex{} + BaseTemplateDir = "templates" +) -func Get(schemaPath, template string) (*JSONSchemaRenderer, error) { +// ClearCache clears the template cache +func ClearCache() { mu.Lock() defer mu.Unlock() + cache = make(map[string]*RequestSchemaTemplate) +} + +// GetCacheSize returns the current cache size +func GetCacheSize() int { + mu.RLock() + defer mu.RUnlock() + return len(cache) +} + +func Get(schemaPath, template string) (*JSONSchemaRenderer, error) { path := fmt.Sprintf("%s:%s", schemaPath, template) + + // Try to get from cache with read lock first + mu.RLock() + if cached, exists := cache[path]; exists { + mu.RUnlock() + return cached.Renderer, nil + } + mu.RUnlock() + + // If not in cache, acquire write lock and create + mu.Lock() + defer mu.Unlock() + + // Double-check after acquiring write lock if cached, exists := cache[path]; exists { return cached.Renderer, nil } @@ -873,3 +1355,10 @@ func Get(schemaPath, template string) (*JSONSchemaRenderer, error) { return cachedTemplate.Renderer, nil } + +// ClearRenderCache clears the rendered HTML cache for a specific renderer +func (r *JSONSchemaRenderer) ClearRenderCache() { + r.cacheMutex.Lock() + defer r.cacheMutex.Unlock() + // Since we're not caching static HTML anymore, this is just for API consistency +} diff --git a/renderer/validation.go b/renderer/validation.go new file mode 100644 index 0000000..04e3a8b --- /dev/null +++ b/renderer/validation.go @@ -0,0 +1,663 @@ +package renderer + +import ( + "fmt" + "strings" + + "github.com/oarkflow/jsonschema" +) + +// ValidationInfo holds comprehensive validation information for a field +type ValidationInfo struct { + // Basic validations + Required bool + MinLength *float64 + MaxLength *float64 + Minimum *jsonschema.Rat + Maximum *jsonschema.Rat + Pattern string + Format string + Enum []interface{} + MultipleOf *jsonschema.Rat + ExclusiveMin *jsonschema.Rat + ExclusiveMax *jsonschema.Rat + MinItems *float64 + MaxItems *float64 + UniqueItems bool + MinProperties *float64 + MaxProperties *float64 + Const interface{} + + // Advanced JSON Schema 2020-12 validations + AllOf []*jsonschema.Schema + AnyOf []*jsonschema.Schema + OneOf []*jsonschema.Schema + Not *jsonschema.Schema + If *jsonschema.Schema + Then *jsonschema.Schema + Else *jsonschema.Schema + DependentSchemas map[string]*jsonschema.Schema + DependentRequired map[string][]string + PrefixItems []*jsonschema.Schema + Items *jsonschema.Schema + Contains *jsonschema.Schema + MaxContains *float64 + MinContains *float64 + UnevaluatedItems *jsonschema.Schema + UnevaluatedProperties *jsonschema.Schema + PropertyNames *jsonschema.Schema + AdditionalProperties *jsonschema.Schema + PatternProperties *jsonschema.SchemaMap + + // Content validations + ContentEncoding *string + ContentMediaType *string + ContentSchema *jsonschema.Schema + + // Metadata + Title *string + Description *string + Default interface{} + Examples []interface{} + Deprecated *bool + ReadOnly *bool + WriteOnly *bool +} + +// extractValidationInfo extracts comprehensive validation information from JSON Schema +func extractValidationInfo(schema *jsonschema.Schema, isRequired bool) ValidationInfo { + validation := ValidationInfo{ + Required: isRequired, + } + + // Basic string validations + if schema.MinLength != nil { + validation.MinLength = schema.MinLength + } + if schema.MaxLength != nil { + validation.MaxLength = schema.MaxLength + } + if schema.Pattern != nil { + validation.Pattern = *schema.Pattern + } + if schema.Format != nil { + validation.Format = *schema.Format + } + + // Numeric validations + if schema.Minimum != nil { + validation.Minimum = schema.Minimum + } + if schema.Maximum != nil { + validation.Maximum = schema.Maximum + } + if schema.ExclusiveMinimum != nil { + validation.ExclusiveMin = schema.ExclusiveMinimum + } + if schema.ExclusiveMaximum != nil { + validation.ExclusiveMax = schema.ExclusiveMaximum + } + if schema.MultipleOf != nil { + validation.MultipleOf = schema.MultipleOf + } + + // Array validations + if schema.MinItems != nil { + validation.MinItems = schema.MinItems + } + if schema.MaxItems != nil { + validation.MaxItems = schema.MaxItems + } + if schema.UniqueItems != nil { + validation.UniqueItems = *schema.UniqueItems + } + if schema.MaxContains != nil { + validation.MaxContains = schema.MaxContains + } + if schema.MinContains != nil { + validation.MinContains = schema.MinContains + } + + // Object validations + if schema.MinProperties != nil { + validation.MinProperties = schema.MinProperties + } + if schema.MaxProperties != nil { + validation.MaxProperties = schema.MaxProperties + } + + // Enum and const values + if schema.Enum != nil { + validation.Enum = schema.Enum + } + if schema.Const != nil { + validation.Const = *schema.Const + } + + // Advanced JSON Schema 2020-12 features + if schema.AllOf != nil { + validation.AllOf = schema.AllOf + } + if schema.AnyOf != nil { + validation.AnyOf = schema.AnyOf + } + if schema.OneOf != nil { + validation.OneOf = schema.OneOf + } + if schema.Not != nil { + validation.Not = schema.Not + } + + // Conditional validations + if schema.If != nil { + validation.If = schema.If + } + if schema.Then != nil { + validation.Then = schema.Then + } + if schema.Else != nil { + validation.Else = schema.Else + } + + // Dependent validations + if schema.DependentSchemas != nil { + validation.DependentSchemas = schema.DependentSchemas + } + if schema.DependentRequired != nil { + validation.DependentRequired = schema.DependentRequired + } + + // Array item validations + if schema.PrefixItems != nil { + validation.PrefixItems = schema.PrefixItems + } + if schema.Items != nil { + validation.Items = schema.Items + } + if schema.Contains != nil { + validation.Contains = schema.Contains + } + if schema.UnevaluatedItems != nil { + validation.UnevaluatedItems = schema.UnevaluatedItems + } + + // Property validations + if schema.PropertyNames != nil { + validation.PropertyNames = schema.PropertyNames + } + if schema.AdditionalProperties != nil { + validation.AdditionalProperties = schema.AdditionalProperties + } + if schema.PatternProperties != nil { + validation.PatternProperties = schema.PatternProperties + } + if schema.UnevaluatedProperties != nil { + validation.UnevaluatedProperties = schema.UnevaluatedProperties + } + + // Content validations + if schema.ContentEncoding != nil { + validation.ContentEncoding = schema.ContentEncoding + } + if schema.ContentMediaType != nil { + validation.ContentMediaType = schema.ContentMediaType + } + if schema.ContentSchema != nil { + validation.ContentSchema = schema.ContentSchema + } + + // Metadata + if schema.Title != nil { + validation.Title = schema.Title + } + if schema.Description != nil { + validation.Description = schema.Description + } + if schema.Default != nil { + validation.Default = schema.Default + } + if schema.Examples != nil { + validation.Examples = schema.Examples + } + if schema.Deprecated != nil { + validation.Deprecated = schema.Deprecated + } + if schema.ReadOnly != nil { + validation.ReadOnly = schema.ReadOnly + } + if schema.WriteOnly != nil { + validation.WriteOnly = schema.WriteOnly + } + + return validation +} + +// generateValidationAttributes creates comprehensive HTML validation attributes +func generateValidationAttributes(validation ValidationInfo) []string { + var attrs []string + + if validation.Required { + attrs = append(attrs, `required="required"`) + } + + // String validations + if validation.MinLength != nil { + attrs = append(attrs, fmt.Sprintf(`minlength="%.0f"`, *validation.MinLength)) + } + if validation.MaxLength != nil { + attrs = append(attrs, fmt.Sprintf(`maxlength="%.0f"`, *validation.MaxLength)) + } + if validation.Pattern != "" { + attrs = append(attrs, fmt.Sprintf(`pattern="%s"`, validation.Pattern)) + } + + // Numeric validations + if validation.Minimum != nil { + minVal, _ := validation.Minimum.Float64() + attrs = append(attrs, fmt.Sprintf(`min="%g"`, minVal)) + } + if validation.Maximum != nil { + maxVal, _ := validation.Maximum.Float64() + attrs = append(attrs, fmt.Sprintf(`max="%g"`, maxVal)) + } + if validation.ExclusiveMin != nil { + exclusiveMinVal, _ := validation.ExclusiveMin.Float64() + attrs = append(attrs, fmt.Sprintf(`data-exclusive-min="%g"`, exclusiveMinVal)) + } + if validation.ExclusiveMax != nil { + exclusiveMaxVal, _ := validation.ExclusiveMax.Float64() + attrs = append(attrs, fmt.Sprintf(`data-exclusive-max="%g"`, exclusiveMaxVal)) + } + if validation.MultipleOf != nil { + stepVal, _ := validation.MultipleOf.Float64() + attrs = append(attrs, fmt.Sprintf(`step="%g"`, stepVal)) + } + + // Array validations (for multi-select and array inputs) + if validation.MinItems != nil { + attrs = append(attrs, fmt.Sprintf(`data-min-items="%.0f"`, *validation.MinItems)) + } + if validation.MaxItems != nil { + attrs = append(attrs, fmt.Sprintf(`data-max-items="%.0f"`, *validation.MaxItems)) + } + if validation.UniqueItems { + attrs = append(attrs, `data-unique-items="true"`) + } + + // Metadata attributes + if validation.Title != nil { + attrs = append(attrs, fmt.Sprintf(`title="%s"`, *validation.Title)) + } + if validation.Description != nil { + attrs = append(attrs, fmt.Sprintf(`data-description="%s"`, *validation.Description)) + } + if validation.Default != nil { + attrs = append(attrs, fmt.Sprintf(`data-default="%v"`, validation.Default)) + } + if validation.ReadOnly != nil && *validation.ReadOnly { + attrs = append(attrs, `readonly="readonly"`) + } + if validation.Deprecated != nil && *validation.Deprecated { + attrs = append(attrs, `data-deprecated="true"`) + } + + // Complex validation indicators (for client-side handling) + if len(validation.AllOf) > 0 { + attrs = append(attrs, `data-has-allof="true"`) + } + if len(validation.AnyOf) > 0 { + attrs = append(attrs, `data-has-anyof="true"`) + } + if len(validation.OneOf) > 0 { + attrs = append(attrs, `data-has-oneof="true"`) + } + if validation.Not != nil { + attrs = append(attrs, `data-has-not="true"`) + } + if validation.If != nil { + attrs = append(attrs, `data-has-conditional="true"`) + } + if len(validation.DependentRequired) > 0 { + attrs = append(attrs, `data-has-dependent-required="true"`) + } + + return attrs +} + +// generateClientSideValidation creates comprehensive JavaScript validation functions +func generateClientSideValidation(fieldPath string, validation ValidationInfo, allFields map[string]ValidationInfo) string { + if !hasValidation(validation) { + return "" + } + + var validations []string + var dependentValidations []string + + // Basic validations + if validation.Required { + validations = append(validations, ` + if (!value || (typeof value === 'string' && value.trim() === '')) { + return 'This field is required'; + }`) + } + + // String validations + if validation.MinLength != nil { + validations = append(validations, fmt.Sprintf(` + if (value && value.length < %.0f) { + return 'Minimum length is %.0f characters'; + }`, *validation.MinLength, *validation.MinLength)) + } + if validation.MaxLength != nil { + validations = append(validations, fmt.Sprintf(` + if (value && value.length > %.0f) { + return 'Maximum length is %.0f characters'; + }`, *validation.MaxLength, *validation.MaxLength)) + } + if validation.Pattern != "" { + validations = append(validations, fmt.Sprintf(` + if (value && !/%s/.test(value)) { + return 'Invalid format'; + }`, strings.ReplaceAll(validation.Pattern, `\`, `\\`))) + } + + // Numeric validations + if validation.Minimum != nil { + minVal, _ := validation.Minimum.Float64() + validations = append(validations, fmt.Sprintf(` + if (value !== '' && parseFloat(value) < %g) { + return 'Minimum value is %g'; + }`, minVal, minVal)) + } + if validation.Maximum != nil { + maxVal, _ := validation.Maximum.Float64() + validations = append(validations, fmt.Sprintf(` + if (value !== '' && parseFloat(value) > %g) { + return 'Maximum value is %g'; + }`, maxVal, maxVal)) + } + if validation.ExclusiveMin != nil { + exclusiveMinVal, _ := validation.ExclusiveMin.Float64() + validations = append(validations, fmt.Sprintf(` + if (value !== '' && parseFloat(value) <= %g) { + return 'Value must be greater than %g'; + }`, exclusiveMinVal, exclusiveMinVal)) + } + if validation.ExclusiveMax != nil { + exclusiveMaxVal, _ := validation.ExclusiveMax.Float64() + validations = append(validations, fmt.Sprintf(` + if (value !== '' && parseFloat(value) >= %g) { + return 'Value must be less than %g'; + }`, exclusiveMaxVal, exclusiveMaxVal)) + } + + // Enum validations + if len(validation.Enum) > 0 { + enumValues := make([]string, len(validation.Enum)) + for i, v := range validation.Enum { + enumValues[i] = fmt.Sprintf("'%v'", v) + } + validations = append(validations, fmt.Sprintf(` + var allowedValues = [%s]; + if (value && allowedValues.indexOf(value) === -1) { + return "Value must be one of: %s"; + }`, strings.Join(enumValues, ", "), strings.Join(enumValues, ", "))) + } + + // Const validation + if validation.Const != nil { + validations = append(validations, fmt.Sprintf(` + if (value !== '%v') { + return 'Value must be %v'; + }`, validation.Const, validation.Const)) + } + + // Array validations + if validation.MinItems != nil { + validations = append(validations, fmt.Sprintf(` + if (Array.isArray(value) && value.length < %.0f) { + return 'Minimum %.0f items required'; + }`, *validation.MinItems, *validation.MinItems)) + } + if validation.MaxItems != nil { + validations = append(validations, fmt.Sprintf(` + if (Array.isArray(value) && value.length > %.0f) { + return 'Maximum %.0f items allowed'; + }`, *validation.MaxItems, *validation.MaxItems)) + } + if validation.UniqueItems { + validations = append(validations, ` + if (Array.isArray(value)) { + var unique = [...new Set(value)]; + if (unique.length !== value.length) { + return 'All items must be unique'; + } + }`) + } + + // Dependent required validations + if len(validation.DependentRequired) > 0 { + for depField, requiredFields := range validation.DependentRequired { + for _, reqField := range requiredFields { + dependentValidations = append(dependentValidations, fmt.Sprintf(` + // Check if %s has value, then %s is required + var depField = document.querySelector('[name="%s"]'); + var reqField = document.querySelector('[name="%s"]'); + if (depField && depField.value && (!reqField || !reqField.value)) { + return 'Field %s is required when %s has a value'; + }`, depField, reqField, reqField, depField)) + } + } + } + + // Complex validations warning (these need server-side validation) + var complexValidationWarnings []string + if len(validation.AllOf) > 0 { + complexValidationWarnings = append(complexValidationWarnings, "// AllOf validation requires server-side validation") + } + if len(validation.AnyOf) > 0 { + complexValidationWarnings = append(complexValidationWarnings, "// AnyOf validation requires server-side validation") + } + if len(validation.OneOf) > 0 { + complexValidationWarnings = append(complexValidationWarnings, "// OneOf validation requires server-side validation") + } + if validation.Not != nil { + complexValidationWarnings = append(complexValidationWarnings, "// Not validation requires server-side validation") + } + if validation.If != nil { + complexValidationWarnings = append(complexValidationWarnings, "// Conditional validation requires server-side validation") + } + + if len(validations) == 0 && len(dependentValidations) == 0 { + return "" + } + + fieldId := strings.ReplaceAll(fieldPath, ".", "_") + allValidations := append(validations, dependentValidations...) + if len(complexValidationWarnings) > 0 { + allValidations = append([]string{strings.Join(complexValidationWarnings, "\n\t\t\t")}, allValidations...) + } + + return fmt.Sprintf(` + + `, fieldPath, fieldId, strings.Join(allValidations, "\n"), + strings.Join(dependentValidations, "\n"), fieldId, fieldId, fieldId, fieldId, fieldId) +} + +// hasValidation checks if validation info contains any validation rules +func hasValidation(validation ValidationInfo) bool { + return validation.Required || + validation.MinLength != nil || + validation.MaxLength != nil || + validation.Minimum != nil || + validation.Maximum != nil || + validation.Pattern != "" || + validation.ExclusiveMin != nil || + validation.ExclusiveMax != nil || + validation.MultipleOf != nil || + len(validation.Enum) > 0 || + validation.Const != nil || + validation.MinItems != nil || + validation.MaxItems != nil || + validation.UniqueItems || + validation.MinProperties != nil || + validation.MaxProperties != nil || + len(validation.AllOf) > 0 || + len(validation.AnyOf) > 0 || + len(validation.OneOf) > 0 || + validation.Not != nil || + validation.If != nil || + validation.Then != nil || + validation.Else != nil || + len(validation.DependentSchemas) > 0 || + len(validation.DependentRequired) > 0 || + len(validation.PrefixItems) > 0 || + validation.Items != nil || + validation.Contains != nil || + validation.MaxContains != nil || + validation.MinContains != nil || + validation.UnevaluatedItems != nil || + validation.UnevaluatedProperties != nil || + validation.PropertyNames != nil || + validation.AdditionalProperties != nil || + validation.PatternProperties != nil || + validation.ContentEncoding != nil || + validation.ContentMediaType != nil || + validation.ContentSchema != nil || + validation.ReadOnly != nil || + validation.WriteOnly != nil || + validation.Deprecated != nil +} + +// getInputTypeFromSchema determines the appropriate HTML input type based on JSON Schema +func getInputTypeFromSchema(schema *jsonschema.Schema) string { + // Check UI type first + if schema.UI != nil { + if uiType, ok := schema.UI["type"].(string); ok { + return uiType + } + } + + // Check format + if schema.Format != nil { + switch *schema.Format { + case "email": + return "email" + case "uri", "uri-reference": + return "url" + case "date": + return "date" + case "time": + return "time" + case "date-time": + return "datetime-local" + case "password": + return "password" + } + } + + // Check type + var typeStr string + if len(schema.Type) > 0 { + typeStr = schema.Type[0] + } + + switch typeStr { + case "string": + return "text" + case "number": + return "number" + case "integer": + return "number" + case "boolean": + return "checkbox" + default: + return "text" + } +} + +// shouldUseTextarea determines if a string field should use textarea +func shouldUseTextarea(schema *jsonschema.Schema) bool { + if schema.UI != nil { + if element, ok := schema.UI["element"].(string); ok { + return element == "textarea" + } + if rows, ok := schema.UI["rows"]; ok && rows != nil { + return true + } + } + + // Use textarea for long strings + if schema.MaxLength != nil && *schema.MaxLength > 255 { + return true + } + + return false +} + +// shouldUseSelect determines if a field should use select element +func shouldUseSelect(schema *jsonschema.Schema) bool { + if schema.UI != nil { + if element, ok := schema.UI["element"].(string); ok { + return element == "select" + } + } + + // Use select for enum values + return len(schema.Enum) > 0 +}