From c9e0ffb2d927af8e21fe3230174c7d740e6119ea Mon Sep 17 00:00:00 2001 From: Antoine Froger Date: Sat, 4 Oct 2025 13:58:59 +0200 Subject: [PATCH 1/2] Add a loader for JSON config --- .github/dependabot.yml | 2 + README.md | 11 +- examples/cli/go.mod | 6 +- examples/cli/go.sum | 42 +++--- examples/rest-api/README.md | 2 +- examples/rest-api/main.go | 2 +- examples/rest-api/survey.json | 159 ++++++++++++++++++++ examples/rest-api/survey.yaml | 115 --------------- loader.go | 170 +++++++++++++++++++++ loader_test.go | 269 ++++++++++++++++++++++++++++++++++ questionnaire.go | 105 +++++++------ questionnaire_test.go | 77 +++------- 12 files changed, 701 insertions(+), 259 deletions(-) create mode 100644 examples/rest-api/survey.json delete mode 100644 examples/rest-api/survey.yaml create mode 100644 loader.go create mode 100644 loader_test.go diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 54cba47..f3a68b8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,3 +24,5 @@ updates: groups: minor-patch-updates: update-types: ["minor", "major"] + +#abc diff --git a/README.md b/README.md index 7224192..ee1d505 100644 --- a/README.md +++ b/README.md @@ -227,14 +227,9 @@ q, err := questionnaire.New(yamlData) ### Medium Term -1. Add loaders for different configuration formats - Introduce a loader interface to handle different config types. These loaders would be responsible for reading from files, parsing YAML/JSON, etc. - - read the configuration from a JSON file - - read the configuration from JSON bytes - - pass pre-configured questions -2. Question type system (text input, numbers, etc.) -3. Question validation (required fields, formats) -4. Performance optimizations for large questionnaires +1. Question type system (text input, numbers, etc.) +2. Question validation (required fields, formats) +3. Performance optimizations for large questionnaires - Pre-compute which questions are available - Cache condition evaluation results diff --git a/examples/cli/go.mod b/examples/cli/go.mod index eed051b..f9ccac9 100644 --- a/examples/cli/go.mod +++ b/examples/cli/go.mod @@ -1,14 +1,12 @@ module github.com/antfroger/go-dynamic-questionnaire/examples/cli -go 1.23.0 - -toolchain go1.23.4 +go 1.24.0 replace github.com/antfroger/go-dynamic-questionnaire => ../../ require github.com/antfroger/go-dynamic-questionnaire v0.0.0 require ( - github.com/expr-lang/expr v1.17.5 // indirect + github.com/expr-lang/expr v1.17.6 // indirect github.com/goccy/go-yaml v1.18.0 // indirect ) diff --git a/examples/cli/go.sum b/examples/cli/go.sum index 5c3c0b4..9169b9b 100644 --- a/examples/cli/go.sum +++ b/examples/cli/go.sum @@ -1,5 +1,7 @@ -github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k= -github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/expr-lang/expr v1.17.6 h1:1h6i8ONk9cexhDmowO/A64VPxHScu7qfSl2k8OlINec= +github.com/expr-lang/expr v1.17.6/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -8,21 +10,25 @@ github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ= -github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= -github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= -github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= -github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= -github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY= +github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= +github.com/onsi/ginkgo/v2 v2.26.0 h1:1J4Wut1IlYZNEAWIV3ALrT9NfiaGW2cDCJQSFQMs/gE= +github.com/onsi/ginkgo/v2 v2.26.0/go.mod h1:qhEywmzWTBUY88kfO0BRvX4py7scov9yR+Az2oavUzw= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= diff --git a/examples/rest-api/README.md b/examples/rest-api/README.md index 23cb5e7..50147b3 100644 --- a/examples/rest-api/README.md +++ b/examples/rest-api/README.md @@ -253,7 +253,7 @@ The API provides clear error responses: ```go var questionnaires = map[string]string{ - "survey": "survey.yaml", + "survey": "survey.json", "your-questionnaire": "your-file.yaml", } ``` diff --git a/examples/rest-api/main.go b/examples/rest-api/main.go index cf1d956..77632cc 100644 --- a/examples/rest-api/main.go +++ b/examples/rest-api/main.go @@ -16,7 +16,7 @@ import ( // Available questionnaires var questionnaires = map[string]string{ - "survey": "survey.yaml", + "survey": "survey.json", } // Response structures diff --git a/examples/rest-api/survey.json b/examples/rest-api/survey.json new file mode 100644 index 0000000..744d509 --- /dev/null +++ b/examples/rest-api/survey.json @@ -0,0 +1,159 @@ +{ + "questions": [ + { + "id": "satisfaction", + "text": "How satisfied are you with our service overall?", + "answers": [ + "Very Satisfied", + "Satisfied", + "Neutral", + "Dissatisfied", + "Very Dissatisfied" + ] + }, + { + "id": "support_quality", + "text": "How would you rate the quality of our customer support?", + "answers": [ + "Excellent", + "Good", + "Average", + "Poor", + "Very Poor" + ] + }, + { + "id": "recommend", + "text": "Would you recommend our service to others?", + "answers": [ + "Definitely", + "Probably", + "Maybe", + "Probably Not", + "Definitely Not" + ], + "depends_on": [ + "satisfaction" + ], + "condition": "answers[\"satisfaction\"] in [1,2]" + }, + { + "id": "improvement_areas", + "text": "What areas do you think we need to improve the most?", + "answers": [ + "Product Features", + "Customer Support", + "Pricing", + "User Experience", + "Documentation" + ], + "depends_on": [ + "satisfaction" + ], + "condition": "answers[\"satisfaction\"] >= 3" + }, + { + "id": "feature_priority", + "text": "Which new feature would be most valuable to you?", + "answers": [ + "Mobile App", + "Advanced Analytics", + "Integration Options", + "Automation Tools", + "Collaboration Features" + ], + "depends_on": [ + "improvement_areas" + ], + "condition": "answers[\"improvement_areas\"] == 1" + }, + { + "id": "support_channel", + "text": "What is your preferred way to get customer support?", + "answers": [ + "Live Chat", + "Email Support", + "Phone Support", + "Self-Service Portal", + "Video Call" + ], + "depends_on": [ + "improvement_areas", + "support_quality" + ], + "condition": "answers[\"improvement_areas\"] == 2 or answers[\"support_quality\"] >= 3" + }, + { + "id": "price_perception", + "text": "How do you feel about our current pricing?", + "answers": [ + "Great Value", + "Fair Price", + "Slightly Expensive", + "Too Expensive", + "Way Too Expensive" + ], + "depends_on": [ + "improvement_areas" + ], + "condition": "answers[\"improvement_areas\"] == 3" + }, + { + "id": "usage_frequency", + "text": "How often do you use our service?", + "answers": [ + "Daily", + "Several times a week", + "Weekly", + "Monthly", + "Rarely" + ] + }, + { + "id": "business_impact", + "text": "How much impact has our service had on your business/work?", + "answers": [ + "Transformational", + "Significant Improvement", + "Moderate Improvement", + "Minor Improvement", + "No Noticeable Impact" + ], + "depends_on": [ + "usage_frequency" + ], + "condition": "answers[\"usage_frequency\"] in 1..3" + }, + { + "id": "alternative_consideration", + "text": "Have you considered switching to a competitor?", + "answers": [ + "Never", + "Rarely Think About It", + "Sometimes", + "Often", + "Actively Looking" + ], + "depends_on": [ + "satisfaction", + "recommend" + ], + "condition": "answers[\"satisfaction\"] >= 4 or answers[\"recommend\"] >= 4" + }, + { + "id": "loyalty_factor", + "text": "What keeps you as our customer?", + "answers": [ + "Superior Product", + "Great Support", + "Fair Pricing", + "Easy to Use", + "Integration Dependencies" + ], + "depends_on": [ + "alternative_consideration" + ], + "condition": "answers[\"alternative_consideration\"] in [1,2]" + } + ] +} diff --git a/examples/rest-api/survey.yaml b/examples/rest-api/survey.yaml deleted file mode 100644 index 90f347d..0000000 --- a/examples/rest-api/survey.yaml +++ /dev/null @@ -1,115 +0,0 @@ -questions: - - id: "satisfaction" - text: "How satisfied are you with our service overall?" - answers: - - "Very Satisfied" - - "Satisfied" - - "Neutral" - - "Dissatisfied" - - "Very Dissatisfied" - - - id: "support_quality" - text: "How would you rate the quality of our customer support?" - answers: - - "Excellent" - - "Good" - - "Average" - - "Poor" - - "Very Poor" - - - id: "recommend" - text: "Would you recommend our service to others?" - answers: - - "Definitely" - - "Probably" - - "Maybe" - - "Probably Not" - - "Definitely Not" - depends_on: ["satisfaction"] - condition: 'answers["satisfaction"] in [1,2]' - - - id: "improvement_areas" - text: "What areas do you think we need to improve the most?" - answers: - - "Product Features" - - "Customer Support" - - "Pricing" - - "User Experience" - - "Documentation" - depends_on: ["satisfaction"] - condition: 'answers["satisfaction"] >= 3' - - - id: "feature_priority" - text: "Which new feature would be most valuable to you?" - answers: - - "Mobile App" - - "Advanced Analytics" - - "Integration Options" - - "Automation Tools" - - "Collaboration Features" - depends_on: ["improvement_areas"] - condition: 'answers["improvement_areas"] == 1' - - - id: "support_channel" - text: "What is your preferred way to get customer support?" - answers: - - "Live Chat" - - "Email Support" - - "Phone Support" - - "Self-Service Portal" - - "Video Call" - depends_on: ["improvement_areas", "support_quality"] - condition: 'answers["improvement_areas"] == 2 or answers["support_quality"] >= 3' - - - id: "price_perception" - text: "How do you feel about our current pricing?" - answers: - - "Great Value" - - "Fair Price" - - "Slightly Expensive" - - "Too Expensive" - - "Way Too Expensive" - depends_on: ["improvement_areas"] - condition: 'answers["improvement_areas"] == 3' - - - id: "usage_frequency" - text: "How often do you use our service?" - answers: - - "Daily" - - "Several times a week" - - "Weekly" - - "Monthly" - - "Rarely" - - - id: "business_impact" - text: "How much impact has our service had on your business/work?" - answers: - - "Transformational" - - "Significant Improvement" - - "Moderate Improvement" - - "Minor Improvement" - - "No Noticeable Impact" - depends_on: ["usage_frequency"] - condition: 'answers["usage_frequency"] in 1..3' - - - id: "alternative_consideration" - text: "Have you considered switching to a competitor?" - answers: - - "Never" - - "Rarely Think About It" - - "Sometimes" - - "Often" - - "Actively Looking" - depends_on: ["satisfaction", "recommend"] - condition: 'answers["satisfaction"] >= 4 or answers["recommend"] >= 4' - - - id: "loyalty_factor" - text: "What keeps you as our customer?" - answers: - - "Superior Product" - - "Great Support" - - "Fair Pricing" - - "Easy to Use" - - "Integration Dependencies" - depends_on: ["alternative_consideration"] - condition: 'answers["alternative_consideration"] in [1,2]' diff --git a/loader.go b/loader.go new file mode 100644 index 0000000..ce980ac --- /dev/null +++ b/loader.go @@ -0,0 +1,170 @@ +package go_dynamic_questionnaire + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/goccy/go-yaml" +) + +// Loader defines the interface for loading questionnaire configurations. +// Each loader implementation is responsible for parsing a specific format +// (YAML, JSON, etc.) and populating a given questionnaire struct. +// +// The Loader interface allows the system to be easily extended to support +// additional configuration formats without modifying the core questionnaire logic. +type Loader interface { + // Load parses the configuration data and populates the provided questionnaire struct. + // The data parameter can be either a file path (string) or raw content ([]byte). + // The q parameter is a pointer to the questionnaire struct to be populated. + // + // Parameters: + // data: Either a file path or raw configuration content + // q: Pointer to the questionnaire struct to populate + // + // Returns: + // error: Parsing errors, file reading errors, or validation errors + Load(data interface{}, q *questionnaire) error +} + +// loadConfig loads a questionnaire configuration from either a file path or content. +// This function handles all the internal logic of selecting the appropriate loader +// and parsing the configuration into the provided questionnaire struct. +// +// Parameters: +// +// config: Either a file path (string) or configuration content ([]byte) +// q: Pointer to questionnaire struct to populate +// +// Returns: +// +// error: Configuration errors, file reading errors, parsing errors, or validation errors +func loadConfig[T config](cfg T, q *questionnaire) error { + loaderInstance, err := getLoaderForConfig(cfg) + if err != nil { + return fmt.Errorf("failed to get loader: %w", err) + } + + if err := loaderInstance.Load(cfg, q); err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + return nil +} + +// getLoaderForConfig determines the appropriate loader based on the configuration data. +// For file paths, it uses the file extension. For byte arrays, it attempts to detect +// the format by examining the content. +func getLoaderForConfig(cfg interface{}) (Loader, error) { + switch v := cfg.(type) { + case string: + // Determine loader based on file extension + ext := strings.ToLower(filepath.Ext(v)) + switch ext { + case ".yaml", ".yml": + return &yamlLoader{}, nil + case ".json": + return &jsonLoader{}, nil + default: + return nil, fmt.Errorf("unsupported file extension %s: expected .yaml, .yml, or .json", ext) + } + case []byte: + // Try to detect format by examining content + content := strings.TrimSpace(string(v)) + if strings.HasPrefix(content, "{") || strings.HasPrefix(content, "[") { + return &jsonLoader{}, nil + } + // Default to YAML for backward compatibility + return &yamlLoader{}, nil + default: + return nil, fmt.Errorf("unsupported config type: expected string (file path) or []byte (content), got %T", cfg) + } +} + +// yamlLoader implements the Loader interface for YAML configuration files. +type yamlLoader struct{} + +// Load parses YAML configuration data and populates the provided questionnaire struct. +func (l *yamlLoader) Load(data interface{}, q *questionnaire) error { + var yamlData []byte + var err error + + switch v := data.(type) { + case string: + // Load from file + yamlData, err = os.ReadFile(v) + if err != nil { + return fmt.Errorf("failed to read YAML file %q: %w", v, err) + } + case []byte: + // Load from byte array + yamlData = v + default: + return fmt.Errorf("unsupported data type for YAML loader: %T", data) + } + + // Unmarshal directly into the questionnaire struct + if err := yaml.Unmarshal(yamlData, q); err != nil { + return fmt.Errorf("failed to parse YAML content: %w", err) + } + + // Basic validation to ensure data structure is valid + if err := validateLoadedQuestionnaire(q); err != nil { + return fmt.Errorf("YAML validation failed: %w", err) + } + + return nil +} + +// jsonLoader implements the Loader interface for JSON configuration files. +type jsonLoader struct{} + +// Load parses JSON configuration data and populates the provided questionnaire struct. +func (l *jsonLoader) Load(data interface{}, q *questionnaire) error { + var jsonData []byte + var err error + + switch v := data.(type) { + case string: + // Load from file + jsonData, err = os.ReadFile(v) + if err != nil { + return fmt.Errorf("failed to read JSON file %q: %w", v, err) + } + case []byte: + // Load from byte array + jsonData = v + default: + return fmt.Errorf("unsupported data type for JSON loader: %T", data) + } + + // Unmarshal directly into the questionnaire struct + if err := json.Unmarshal(jsonData, q); err != nil { + return fmt.Errorf("failed to parse JSON content: %w", err) + } + + // Basic validation to ensure data structure is valid + if err := validateLoadedQuestionnaire(q); err != nil { + return fmt.Errorf("JSON validation failed: %w", err) + } + + return nil +} + +// validateLoadedQuestionnaire performs basic structural validation on the loaded questionnaire data. +// This is called by each loader after parsing to ensure the data structure is valid. +// Business logic validation (duplicate IDs, dependencies, etc.) is handled by the main validation. +func validateLoadedQuestionnaire(q *questionnaire) error { + // Ensure slices are initialized (not nil) + if q.Questions == nil { + q.Questions = []question{} + } + if q.Remarks == nil { + q.Remarks = []closingRemark{} + } + + return nil +} diff --git a/loader_test.go b/loader_test.go new file mode 100644 index 0000000..90bd899 --- /dev/null +++ b/loader_test.go @@ -0,0 +1,269 @@ +package go_dynamic_questionnaire + +import ( + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Loader", func() { + Describe("getLoaderForConfig", func() { + Context("with string file paths", func() { + It("should return yamlLoader for .yaml files", func() { + loader, err := getLoaderForConfig("test.yaml") + Expect(err).ToNot(HaveOccurred()) + Expect(loader).To(BeAssignableToTypeOf(&yamlLoader{})) + }) + + It("should return yamlLoader for .yml files", func() { + loader, err := getLoaderForConfig("test.yml") + Expect(err).ToNot(HaveOccurred()) + Expect(loader).To(BeAssignableToTypeOf(&yamlLoader{})) + }) + + It("should return jsonLoader for .json files", func() { + loader, err := getLoaderForConfig("test.json") + Expect(err).ToNot(HaveOccurred()) + Expect(loader).To(BeAssignableToTypeOf(&jsonLoader{})) + }) + + It("should return error for unsupported file extensions", func() { + loader, err := getLoaderForConfig("test.txt") + Expect(err).To(MatchError("unsupported file extension .txt: expected .yaml, .yml, or .json")) + Expect(loader).To(BeNil()) + }) + }) + + Context("with byte array content", func() { + It("should return jsonLoader for JSON content", func() { + jsonContent := []byte(`{"questions": []}`) + loader, err := getLoaderForConfig(jsonContent) + Expect(err).ToNot(HaveOccurred()) + Expect(loader).To(BeAssignableToTypeOf(&jsonLoader{})) + }) + + It("should return yamlLoader for YAML content", func() { + yamlContent := []byte("questions: []") + loader, err := getLoaderForConfig(yamlContent) + Expect(err).ToNot(HaveOccurred()) + Expect(loader).To(BeAssignableToTypeOf(&yamlLoader{})) + }) + + It("should return yamlLoader for empty content (default)", func() { + emptyContent := []byte("") + loader, err := getLoaderForConfig(emptyContent) + Expect(err).ToNot(HaveOccurred()) + Expect(loader).To(BeAssignableToTypeOf(&yamlLoader{})) + }) + + It("should return jsonLoader for array content", func() { + arrayContent := []byte(`[{"id": "test"}]`) + loader, err := getLoaderForConfig(arrayContent) + Expect(err).ToNot(HaveOccurred()) + Expect(loader).To(BeAssignableToTypeOf(&jsonLoader{})) + }) + }) + + Context("with unsupported types", func() { + It("should return error for unsupported types", func() { + loader, err := getLoaderForConfig(123) + Expect(err).To(MatchError("unsupported config type: expected string (file path) or []byte (content), got int")) + Expect(loader).To(BeNil()) + }) + }) + }) + + Describe("yamlLoader", func() { + var loader *yamlLoader + + BeforeEach(func() { + loader = &yamlLoader{} + }) + + Context("with string file path", func() { + It("should load YAML from file", func() { + content := []byte(` +questions: + - id: "q1" + text: "Question 1?" + answers: + - "Answer 1" + - "Answer 2" +`) + tmpFile, err := os.CreateTemp("", "questionnaire-*.yaml") + Expect(err).To(BeNil()) + defer func(name string) { + _ = os.Remove(name) + }(tmpFile.Name()) + + _, err = tmpFile.Write(content) + Expect(err).To(BeNil()) + err = tmpFile.Close() + Expect(err).To(BeNil()) + + q := &questionnaire{} + err = loader.Load(tmpFile.Name(), q) + Expect(err).To(BeNil()) + Expect(q).NotTo(BeNil()) + }) + + It("should return error for non-existent file", func() { + q := &questionnaire{} + err := loader.Load("non-existent.yaml", q) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to read YAML file")) + }) + }) + + Context("with byte array content", func() { + It("should load YAML from bytes", func() { + yamlContent := []byte(` +questions: + - id: test1 + text: Test question 1 + answers: ["Yes", "No"] + - id: test2 + text: Test question 2 + answers: ["A", "B", "C"] +`) + q := &questionnaire{} + err := loader.Load(yamlContent, q) + Expect(err).ToNot(HaveOccurred()) + Expect(q.Questions).ToNot(BeNil()) + Expect(len(q.Questions)).To(Equal(2)) + Expect(q.Questions[0].Id).To(Equal("test1")) + Expect(q.Questions[1].Id).To(Equal("test2")) + }) + + It("should return error for invalid YAML", func() { + invalidYaml := []byte("invalid: yaml: content: [") + q := &questionnaire{} + err := loader.Load(invalidYaml, q) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to parse YAML content")) + }) + }) + + Context("with unsupported data types", func() { + It("should return error for unsupported types", func() { + q := &questionnaire{} + err := loader.Load(123, q) + Expect(err).To(MatchError("unsupported data type for YAML loader: int")) + }) + }) + }) + + Describe("jsonLoader", func() { + var loader *jsonLoader + + BeforeEach(func() { + loader = &jsonLoader{} + }) + + Context("with string file path", func() { + It("should load JSON from file", func() { + content := []byte(` +{ + "questions": [ + { + "id": "q1", + "text": "Question 1?", + "answers": [ + "Answer 1", + "Answer 2" + ] + } + ] +} +`) + tmpFile, err := os.CreateTemp("", "questionnaire-*.json") + Expect(err).To(BeNil()) + defer func(name string) { + _ = os.Remove(name) + }(tmpFile.Name()) + + _, err = tmpFile.Write(content) + Expect(err).To(BeNil()) + err = tmpFile.Close() + Expect(err).To(BeNil()) + + q := &questionnaire{} + err = loader.Load(tmpFile.Name(), q) + Expect(err).To(BeNil()) + Expect(q).NotTo(BeNil()) + }) + + It("should return error for non-existent file", func() { + q := &questionnaire{} + err := loader.Load("non-existent.json", q) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to read JSON file")) + }) + }) + + Context("with byte array content", func() { + It("should load JSON from bytes", func() { + jsonContent := []byte(`{ + "questions": [ + { + "id": "test1", + "text": "Test question 1", + "answers": ["Yes", "No"] + }, + { + "id": "test2", + "text": "Test question 2", + "answers": ["A", "B", "C"] + } + ] +}`) + q := &questionnaire{} + err := loader.Load(jsonContent, q) + Expect(err).ToNot(HaveOccurred()) + Expect(q.Questions).ToNot(BeNil()) + Expect(len(q.Questions)).To(Equal(2)) + Expect(q.Questions[0].Id).To(Equal("test1")) + Expect(q.Questions[1].Id).To(Equal("test2")) + }) + + It("should return error for invalid JSON", func() { + invalidJson := []byte(`{"questions": [invalid json}`) + q := &questionnaire{} + err := loader.Load(invalidJson, q) + Expect(err).To(MatchError(ContainSubstring("failed to parse JSON content"))) + }) + }) + + Context("with unsupported data types", func() { + It("should return error for unsupported types", func() { + q := &questionnaire{} + err := loader.Load(123, q) + Expect(err).To(MatchError("unsupported data type for JSON loader: int")) + }) + }) + }) + + Describe("validateLoadedQuestionnaire", func() { + It("should initialize nil slices", func() { + q := &questionnaire{} + err := validateLoadedQuestionnaire(q) + Expect(err).ToNot(HaveOccurred()) + Expect(q.Questions).ToNot(BeNil()) + Expect(q.Remarks).ToNot(BeNil()) + Expect(q.Questions).To(HaveLen(0)) + Expect(q.Remarks).To(HaveLen(0)) + }) + + It("should not modify existing slices", func() { + q := &questionnaire{ + Questions: []question{{Id: "test", Text: "Test", Answers: []string{"Yes"}}}, + Remarks: []closingRemark{{Id: "remark", Text: "Test remark"}}, + } + err := validateLoadedQuestionnaire(q) + Expect(err).ToNot(HaveOccurred()) + Expect(len(q.Questions)).To(Equal(1)) + Expect(len(q.Remarks)).To(Equal(1)) + }) + }) +}) diff --git a/questionnaire.go b/questionnaire.go index 764895c..99b4f92 100644 --- a/questionnaire.go +++ b/questionnaire.go @@ -26,10 +26,8 @@ package go_dynamic_questionnaire import ( "fmt" - "os" "github.com/expr-lang/expr" - "github.com/goccy/go-yaml" ) type ( @@ -78,41 +76,41 @@ type ( } // config is a constraint interface for configuration inputs to the New function. - // It accepts either a file path (string) or raw YAML content ([]byte). + // It accepts either a file path (string) or raw content ([]byte). // // Examples: // New("path/to/questionnaire.yaml") // Load from file - // New([]byte("questions: ...")) // Load from YAML content + // New([]byte("questions: ...")) // Load from content config interface { string | []byte } // questionnaire is the internal implementation of the Questionnaire interface. - // It contains the parsed questions and closing remarks from YAML configuration. + // It contains the parsed questions and closing remarks from configuration. // // This struct is not exported as users should interact with the Questionnaire interface. // Instances are created through the New function and are immutable after creation. questionnaire struct { - Questions []question `yaml:"questions"` // List of all questions in the questionnaire - Remarks []closingRemark `yaml:"closing_remarks"` // List of all closing remarks + Questions []question `yaml:"questions" json:"questions"` // List of all questions in the questionnaire + Remarks []closingRemark `yaml:"closing_remarks" json:"closing_remarks"` // List of all closing remarks } // question represents a single question in the questionnaire configuration. // Questions can have conditional logic that determines when they should be shown. question struct { - Id string `yaml:"id"` // Unique identifier for the question - Text string `yaml:"text"` // The question text shown to users - Answers []string `yaml:"answers"` // List of possible answer choices - DependsOn []string `yaml:"depends_on,omitempty"` // Explicit list of question IDs this question depends on (required if condition is used) - Condition string `yaml:"condition,omitempty"` // Optional expression to determine if question should be shown + Id string `yaml:"id" json:"id"` // Unique identifier for the question + Text string `yaml:"text" json:"text"` // The question text shown to users + Answers []string `yaml:"answers" json:"answers"` // List of possible answer choices + DependsOn []string `yaml:"depends_on,omitempty" json:"depends_on,omitempty"` // Explicit list of question IDs this question depends on (required if condition is used) + Condition string `yaml:"condition,omitempty" json:"condition,omitempty"` // Optional expression to determine if question should be shown } // closingRemark represents a message shown when the questionnaire is completed. // Like questions, closing remarks can have conditional logic. closingRemark struct { - Id string `yaml:"id"` // Unique identifier for the remark - Text string `yaml:"text"` // The remark text shown to users - Condition string `yaml:"condition,omitempty"` // Optional expression to determine if remark should be shown + Id string `yaml:"id" json:"id"` // Unique identifier for the remark + Text string `yaml:"text" json:"text"` // The remark text shown to users + Condition string `yaml:"condition,omitempty" json:"condition,omitempty"` // Optional expression to determine if remark should be shown } // Response represents the complete response from processing a questionnaire step. @@ -173,25 +171,26 @@ type ( } ) -// New creates a new Questionnaire instance from either a file path or YAML content. +// New creates a new Questionnaire instance from either a file path or content (YAML or JSON). // // The function accepts two types of input: -// - string: Path to a YAML configuration file -// - []byte: Raw YAML content as bytes +// - string: Path to a configuration file (.yaml, .yml, or .json) +// - []byte: Raw configuration content (YAML or JSON) // // Parameters: // -// config: Either a file path (string) or YAML content ([]byte). -// The YAML must contain 'questions' and optionally 'closing_remarks' sections. +// config: Either a file path (string) or configuration content ([]byte). +// The configuration must contain 'questions' and optionally 'closing_remarks' sections. +// Supported formats: YAML (.yaml, .yml) and JSON (.json) // // Returns: // // Questionnaire: A fully configured questionnaire instance ready for use. // The instance is immutable and thread-safe. -// error: Returns configuration errors, file reading errors, YAML parsing errors, +// error: Returns configuration errors, file reading errors, parsing errors, // or validation errors if the questionnaire structure is invalid. // -// Example usage with file path: +// Example usage with YAML file: // // q, err := gdq.New("surveys/customer-feedback.yaml") // if err != nil { @@ -219,16 +218,46 @@ type ( // return fmt.Errorf("questionnaire creation failed: %w", err) // } // +// Example usage with JSON content: +// +// jsonData := []byte(`{ +// "questions": [ +// { +// "id": "satisfaction", +// "text": "How satisfied are you with our service?", +// "answers": ["Very satisfied", "Satisfied", "Neutral", "Dissatisfied"] +// }, +// { +// "id": "recommend", +// "text": "Would you recommend us?", +// "answers": ["Yes", "No"], +// "condition": "answers[\"satisfaction\"] <= 2" +// } +// ], +// "closing_remarks": [ +// { +// "id": "thanks", +// "text": "Thank you for your feedback!" +// } +// ] +// }`) +// +// q, err := gdq.New(jsonData) +// if err != nil { +// return fmt.Errorf("questionnaire creation failed: %w", err) +// } +// // The function validates the questionnaire structure during creation, checking for: // - Duplicate question IDs // - Empty question IDs // - Questions without answer options -// - Invalid YAML syntax +// - Invalid configuration syntax func New[T config](config T) (Questionnaire, error) { q := &questionnaire{} if err := loadConfig(config, q); err != nil { return nil, fmt.Errorf("failed to load config: %w", err) } + if err := q.validateQuestionnaireIntegrity(); err != nil { return nil, fmt.Errorf("questionnaire validation failed: %w", err) } @@ -236,36 +265,6 @@ func New[T config](config T) (Questionnaire, error) { return q, nil } -// loadConfig loads a questionnaire configuration from a file path or YAML content. -func loadConfig[T config](config T, q *questionnaire) error { - switch v := any(config).(type) { - case string: - return loadYamlFileConfig(v, q) - case []byte: - return loadYamlConfig(v, q) - } - - return fmt.Errorf("unsupported config type: expected string (file path) or []byte (YAML content), got %T", config) -} - -// loadYamlFileConfig loads a questionnaire configuration from a YAML file. -func loadYamlFileConfig(configPath string, q *questionnaire) error { - data, err := os.ReadFile(configPath) - if err != nil { - return fmt.Errorf("failed to read config file %q: %w", configPath, err) - } - - return loadYamlConfig(data, q) -} - -// loadYamlConfig loads a questionnaire configuration from YAML content. -func loadYamlConfig(data []byte, q *questionnaire) error { - if err := yaml.Unmarshal(data, q); err != nil { - return fmt.Errorf("failed to parse questionnaire config: %w", err) - } - return nil -} - // validateQuestionnaireIntegrity validates the questionnaire configuration at load time func (q *questionnaire) validateQuestionnaireIntegrity() error { questionIDs := make(map[string]bool) diff --git a/questionnaire_test.go b/questionnaire_test.go index 8aa5f95..6b9e207 100644 --- a/questionnaire_test.go +++ b/questionnaire_test.go @@ -1,74 +1,19 @@ package go_dynamic_questionnaire_test import ( - "errors" "math" - "os" gdq "github.com/antfroger/go-dynamic-questionnaire" - "github.com/goccy/go-yaml" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("Questionnaire", func() { Describe("New", func() { - When("config is a file", func() { - When("the given file does not exist", func() { - It("returns an error", func() { - _, err := gdq.New("testdata/missing.yaml") - Expect(err).To(MatchError(ContainSubstring(`failed to read config file "testdata/missing.yaml"`))) - Expect(errors.Is(err, os.ErrNotExist)).To(BeTrue()) - }) - }) - - When("the given file exists", func() { - It("should load a questionnaire from the file", func() { - content := []byte(` -questions: - - id: "q1" - text: "Question 1?" - answers: - - "Answer 1" - - "Answer 2" -`) - tmpFile, err := os.CreateTemp("", "questionnaire-*.yaml") - Expect(err).To(BeNil()) - defer func(name string) { - _ = os.Remove(name) - }(tmpFile.Name()) - - _, err = tmpFile.Write(content) - Expect(err).To(BeNil()) - err = tmpFile.Close() - Expect(err).To(BeNil()) - - q, err := gdq.New(tmpFile.Name()) - Expect(err).To(BeNil()) - Expect(q).NotTo(BeNil()) - }) - }) - }) - - When("config is yaml content", func() { - It("should load the questionnaire from bytes", func() { - q, err := gdq.New([]byte(` -questions: - - id: "q1" - text: "Question 1?" - answers: - - "Answer 1" - - "Answer 2" -`)) - Expect(err).To(BeNil()) - Expect(q).NotTo(BeNil()) - }) - - It("should handle invalid YAML content", func() { - _, err := gdq.New([]byte(`invalid yaml`)) - Expect(err).To(MatchError(ContainSubstring(`failed to parse questionnaire config`))) - var yamlErr *yaml.UnexpectedNodeTypeError - Expect(errors.As(err, &yamlErr)).To(BeTrue()) + When("config can't be loaded", func() { + It("returns an error", func() { + _, err := gdq.New("testdata/missing.yaml") + Expect(err).To(MatchError(ContainSubstring(`failed to load config`))) }) }) @@ -86,6 +31,20 @@ questions: }) }) + When("validation fails", func() { + It("should return validation error", func() { + _, err := gdq.New([]byte(` +questions: + - id: "" + text: "Question with empty ID" + answers: ["Yes", "No"] +`)) + Expect(err).To(MatchError(ContainSubstring(`questionnaire validation failed`))) + }) + }) + }) + + Describe("Validation", func() { When("questionnaire has empty question IDs", func() { It("should return a graceful error", func() { _, err := gdq.New([]byte(` From 26ec37a01de3288f898bea3be9c453cef8e0ee99 Mon Sep 17 00:00:00 2001 From: Antoine Froger Date: Mon, 6 Oct 2025 19:31:27 +0200 Subject: [PATCH 2/2] Remove mistake --- .github/dependabot.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f3a68b8..54cba47 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,5 +24,3 @@ updates: groups: minor-patch-updates: update-types: ["minor", "major"] - -#abc