diff --git a/README.md b/README.md index 852304a..4496b7d 100644 --- a/README.md +++ b/README.md @@ -34,29 +34,17 @@ func main() { - [Solr-Go](#solr-go) - [Contents](#contents) - - [Goals](#goals) - [Notes](#notes) - [Installation](#installation) - [Usage](#usage) - [Features](#features) - [Contributing](#contributing) - -## Goals - -The goal of this project is to support the majority of operations in Solr via API. - -* Basic operations: querying, indexing, auto-suggest etc. -* Admin operations: - * [Schema API](https://lucene.apache.org/solr/guide/8_5/schema-api.html) - * [Config API](https://lucene.apache.org/solr/guide/8_5/config-api.html) - * [Configset API](https://lucene.apache.org/solr/guide/8_5/configsets-api.html) - ## Notes * This is a *WORK IN-PROGRESS*, API might change a lot before *v1* * I'm currently using my project as the testbed for this module -* Tested on [Solr 8.5](https://lucene.apache.org/solr/guide/8_5/) +* Tested using [Solr 8.5](https://lucene.apache.org/solr/guide/8_5/) ## Installation @@ -93,9 +81,10 @@ A detailed documentation shall follow after *v1*. For now you can start looking - [ ] Example - [x] Suggester client - [x] Unified solr client +- [x] Config API client - [ ] Collections API client - [ ] Configset API client -- [ ] Config API client +- [ ] SolrCloud support (V2 API) - [ ] Basic auth support - [ ] Documentation diff --git a/config/client.go b/config/client.go new file mode 100644 index 0000000..b098d05 --- /dev/null +++ b/config/client.go @@ -0,0 +1,137 @@ +package config + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/pkg/errors" +) + +// Client is a config API client +type Client interface { + GetConfig(ctx context.Context, collection string) (*Response, error) + SendCommands(ctx context.Context, collection string, commands ...Commander) error +} + +type client struct { + host string + port int + proto string + httpClient *http.Client +} + +// New is a factory for config client +func New(host string, port int) Client { + proto := "http" + return &client{ + host: host, + port: port, + proto: proto, + httpClient: &http.Client{ + Timeout: time.Second * 60, + }, + } +} + +// NewWithHTTPClient is a factory for config client +func NewWithHTTPClient(host string, port int, httpClient *http.Client) Client { + proto := "http" + return &client{ + host: host, + port: port, + proto: proto, + httpClient: httpClient, + } +} + +func (c *client) GetConfig(ctx context.Context, collection string) (*Response, error) { + theURL, err := c.buildURL(collection) + if err != nil { + return nil, errors.Wrap(err, "build url") + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, theURL.String(), nil) + if err != nil { + return nil, errors.Wrap(err, "new http request") + } + + return c.do(httpReq) +} + +func (c *client) SendCommands(ctx context.Context, collection string, commands ...Commander) error { + if len(commands) == 0 { + return nil + } + + theURL, err := c.buildURL(collection) + if err != nil { + return errors.Wrap(err, "build url") + } + + // build commands + commandStrs := []string{} + for _, command := range commands { + commandStr, err := command.Command() + if err != nil { + return errors.Wrap(err, "build commad") + } + + commandStrs = append(commandStrs, commandStr) + } + + // send commands to solr + requestBody := "{" + strings.Join(commandStrs, ",") + "}" + + return c.sendCommands(ctx, theURL.String(), []byte(requestBody)) +} + +func (c *client) buildURL(collection string) (*url.URL, error) { + u, err := url.Parse(fmt.Sprintf("%s://%s:%d/solr/%s/config", + c.proto, c.host, c.port, collection)) + if err != nil { + return nil, errors.Wrap(err, "parse url") + } + + return u, nil +} + +func (c *client) sendCommands(ctx context.Context, urlStr string, body []byte) error { + httpReq, err := http.NewRequestWithContext(ctx, + http.MethodPost, urlStr, bytes.NewReader(body)) + if err != nil { + return errors.Wrap(err, "new http request") + } + + _, err = c.do(httpReq) + if err != nil { + return errors.Wrap(err, "send commands") + } + + return err +} + +func (c *client) do(httpReq *http.Request) (*Response, error) { + httpReq.Header.Add("content-type", "application/json") + httpResp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, errors.Wrap(err, "http do request") + } + + var resp Response + err = json.NewDecoder(httpResp.Body).Decode(&resp) + if err != nil { + return nil, errors.Wrap(err, "decode response") + } + + if httpResp.StatusCode > http.StatusOK { + return nil, resp.Error + } + + return &resp, nil +} diff --git a/config/client_test.go b/config/client_test.go new file mode 100644 index 0000000..c8477b4 --- /dev/null +++ b/config/client_test.go @@ -0,0 +1,124 @@ +package config_test + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/dnaeon/go-vcr/recorder" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + solrconfig "github.com/stevenferrer/solr-go/config" +) + +func TestClient(t *testing.T) { + ctx := context.Background() + collection := "gettingstarted" + host := "localhost" + port := 8983 + timeout := time.Second * 6 + + // only for covering + _ = solrconfig.New(host, port) + + t.Run("retrieve config", func(t *testing.T) { + rec, err := recorder.New("fixtures/retrieve-config") + require.NoError(t, err) + defer rec.Stop() + + configClient := solrconfig.NewWithHTTPClient(host, port, &http.Client{ + Timeout: timeout, + Transport: rec, + }) + + resp, err := configClient.GetConfig(ctx, collection) + require.NoError(t, err) + + assert.NotNil(t, resp) + }) + + t.Run("send commands", func(t *testing.T) { + t.Run("ok", func(t *testing.T) { + rec, err := recorder.New("fixtures/send-commands-ok") + require.NoError(t, err) + defer rec.Stop() + + configClient := solrconfig.NewWithHTTPClient(host, port, &http.Client{ + Timeout: timeout, + Transport: rec, + }) + + setUpdateHandlerAutoCommit := solrconfig.NewSetPropCommand( + "updateHandler.autoCommit.maxTime", 15000) + + addSuggestComponent := solrconfig.NewComponentCommand( + solrconfig.AddSearchComponent, + map[string]interface{}{ + "name": "suggest", + "class": "solr.SuggestComponent", + "suggester": map[string]string{ + "name": "mySuggester", + "lookupImpl": "FuzzyLookupFactory", + "dictionaryImpl": "DocumentDictionaryFactory", + "field": "_text_", + "suggestAnalyzerFieldType": "text_general", + }, + }, + ) + + addSuggestHandler := solrconfig.NewComponentCommand( + solrconfig.AddRequestHandler, + map[string]interface{}{ + "name": "/suggest", + "class": "solr.SearchHandler", + "startup": "lazy", + "defaults": map[string]interface{}{ + "suggest": true, + "suggest.count": 10, + "suggest.dictionary": "mySuggester", + }, + "components": []string{"suggest"}, + }, + ) + + err = configClient.SendCommands(ctx, collection, + setUpdateHandlerAutoCommit, + addSuggestComponent, + addSuggestHandler, + ) + assert.NoError(t, err) + }) + + t.Run("error", func(t *testing.T) { + rec, err := recorder.New("fixtures/send-commands-error") + require.NoError(t, err) + defer rec.Stop() + + configClient := solrconfig.NewWithHTTPClient(host, port, &http.Client{ + Timeout: timeout, + Transport: rec, + }) + + addSuggestComponent := solrconfig.NewComponentCommand( + solrconfig.AddSearchComponent, + map[string]interface{}{ + "name": "suggest", + "class": "solr.SuggestComponent", + "suggester": map[string]string{ + "name": "mySuggester", + "lookupImpl": "FuzzyLookupFactory-BLAH-BLAH", + "dictionaryImpl": "DocumentDictionaryFactory-BLAH-BLAH", + "field": "_text_", + "suggestAnalyzerFieldType": "text_general", + }, + }, + ) + + err = configClient.SendCommands(ctx, collection, addSuggestComponent) + assert.Error(t, err) + }) + }) + +} diff --git a/config/commands.go b/config/commands.go new file mode 100644 index 0000000..7d2d6cf --- /dev/null +++ b/config/commands.go @@ -0,0 +1,94 @@ +package config + +import ( + "encoding/json" + "fmt" + + "github.com/pkg/errors" +) + +// Commander is a contract for config commands +type Commander interface { + // Command builds the config command + Command() (string, error) +} + +// SetPropCommand is a command to set common properties. +// See: https://lucene.apache.org/solr/guide/8_5/config-api.html#commands-for-common-properties +type SetPropCommand struct { + prop string + val interface{} +} + +// NewSetPropCommand is a factory for SetPropCommand +func NewSetPropCommand(prop string, val interface{}) Commander { + return SetPropCommand{prop: prop, val: val} +} + +func (c SetPropCommand) Command() (string, error) { + m := map[string]interface{}{c.prop: c.val} + b, err := json.Marshal(m) + if err != nil { + return "", errors.Wrap(err, "marshal command") + } + + return `"set-property": ` + string(b), nil +} + +// UnsetPropCommand is a command to unset common properties. +// See: https://lucene.apache.org/solr/guide/8_5/config-api.html#commands-for-common-properties +type UnsetPropCommand struct { + prop string +} + +// NewUnsetPropCommand is a factory for UnsetPropCommand +func NewUnsetPropCommand(prop string) Commander { + return UnsetPropCommand{prop: prop} +} + +func (c UnsetPropCommand) Command() (string, error) { + return fmt.Sprintf(`"unset-property": %q`, c.prop), nil +} + +// CommandType is a component command type +type CommandType string + +// Basic commands for components +const ( + AddRequestHandler CommandType = "add-requesthandler" + UpdateRequestHandler CommandType = "update-requesthandler" + DeleteRequestHandler CommandType = "delete-requesthandler" + AddSearchComponent CommandType = "add-searchcomponent" + UpdateSearchComponent CommandType = "update-searchcomponent" + DeleteSearchComponent CommandType = "delete-searchcomponent" + AddInitParams CommandType = "add-initparams" + UpdateInitParams CommandType = "update-initparams" + DeleteInitParams CommandType = "delete-initparams" + AddQueryResponseWriter CommandType = "add-queryresponsewriter" + UpdateQueryResponseWriter CommandType = "update-queryresponsewriter" + DeleteQueryResponseWriter CommandType = "delete-queryresponsewriter" +) + +// ComponentCommand is a component command. +// See: https://lucene.apache.org/solr/guide/8_5/config-api.html#commands-for-handlers-and-components +type ComponentCommand struct { + CommandType CommandType + Body map[string]interface{} +} + +// NewComponentCommand is a factory for component command +func NewComponentCommand(commandType CommandType, body map[string]interface{}) Commander { + return &ComponentCommand{ + CommandType: commandType, + Body: body, + } +} + +func (c *ComponentCommand) Command() (string, error) { + b, err := json.Marshal(c.Body) + if err != nil { + return "", errors.Wrap(err, "marshal command body") + } + + return fmt.Sprintf(`"%s": `+string(b), c.CommandType), nil +} diff --git a/config/commands_test.go b/config/commands_test.go new file mode 100644 index 0000000..2101e4d --- /dev/null +++ b/config/commands_test.go @@ -0,0 +1,53 @@ +package config_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + solrconfig "github.com/stevenferrer/solr-go/config" +) + +func TestCommands(t *testing.T) { + t.Run("common properties", func(t *testing.T) { + t.Run("set property", func(t *testing.T) { + setPropCommand := solrconfig.NewSetPropCommand("updateHandler.autoCommit.maxTime", 15000) + + got, err := setPropCommand.Command() + require.NoError(t, err) + + expected := `"set-property": {"updateHandler.autoCommit.maxTime":15000}` + assert.Equal(t, expected, got) + }) + + t.Run("unset property", func(t *testing.T) { + unsetPropCommand := solrconfig.NewUnsetPropCommand("updateHandler.autoCommit.maxTime") + + got, err := unsetPropCommand.Command() + require.NoError(t, err) + + expected := `"unset-property": "updateHandler.autoCommit.maxTime"` + assert.Equal(t, expected, got) + }) + }) + + t.Run("component commands", func(t *testing.T) { + // TODO: add table test for all command types + addReqHandlerCommand := solrconfig.NewComponentCommand( + solrconfig.AddRequestHandler, + map[string]interface{}{ + "name": "/mypath", + "class": "solr.DumpRequestHandler", + "defaults": map[string]interface{}{"x": "y", "a": "b", "rows": 10}, + "useParams": "x", + }, + ) + + got, err := addReqHandlerCommand.Command() + require.NoError(t, err) + + expected := `"add-requesthandler": {"class":"solr.DumpRequestHandler","defaults":{"a":"b","rows":10,"x":"y"},"name":"/mypath","useParams":"x"}` + assert.Equal(t, expected, got) + }) +} diff --git a/config/doc.go b/config/doc.go new file mode 100644 index 0000000..7d8331e --- /dev/null +++ b/config/doc.go @@ -0,0 +1,2 @@ +// Package config contains tools for interacting with config API +package config diff --git a/config/fixtures/retrieve-config.yaml b/config/fixtures/retrieve-config.yaml new file mode 100644 index 0000000..b3cb4a4 --- /dev/null +++ b/config/fixtures/retrieve-config.yaml @@ -0,0 +1,344 @@ +--- +version: 1 +interactions: +- request: + body: "" + form: {} + headers: + Content-Type: + - application/json + url: http://localhost:8983/solr/gettingstarted/config + method: GET + response: + body: | + { + "responseHeader":{ + "status":0, + "QTime":22}, + "config":{ + "luceneMatchVersion":"org.apache.lucene.util.Version:8.5.2", + "updateHandler":{ + "indexWriter":{"closeWaitsForMerges":true}, + "commitWithin":{"softCommit":true}, + "autoCommit":{ + "maxDocs":-1, + "maxTime":15000, + "openSearcher":false}, + "autoSoftCommit":{ + "maxDocs":-1, + "maxTime":-1}}, + "query":{ + "useFilterForSortedQuery":false, + "queryResultWindowSize":20, + "queryResultMaxDocsCached":200, + "enableLazyFieldLoading":true, + "maxBooleanClauses":1024, + "filterCache":{ + "autowarmCount":"0", + "size":"512", + "initialSize":"512", + "class":"solr.FastLRUCache", + "name":"filterCache"}, + "queryResultCache":{ + "autowarmCount":"0", + "size":"512", + "initialSize":"512", + "class":"solr.LRUCache", + "name":"queryResultCache"}, + "documentCache":{ + "autowarmCount":"0", + "size":"512", + "initialSize":"512", + "class":"solr.LRUCache", + "name":"documentCache"}, + "":{ + "size":"10000", + "showItems":"-1", + "initialSize":"10", + "name":"fieldValueCache"}}, + "requestHandler":{ + "/select":{ + "name":"/select", + "class":"solr.SearchHandler", + "defaults":{ + "echoParams":"explicit", + "rows":10}}, + "/query":{ + "name":"/query", + "class":"solr.SearchHandler", + "defaults":{ + "echoParams":"explicit", + "wt":"json", + "indent":"true"}}, + "/spell":{ + "startup":"lazy", + "name":"/spell", + "class":"solr.SearchHandler", + "defaults":{ + "spellcheck.dictionary":"default", + "spellcheck":"on", + "spellcheck.extendedResults":"true", + "spellcheck.count":"10", + "spellcheck.alternativeTermCount":"5", + "spellcheck.maxResultsForSuggest":"5", + "spellcheck.collate":"true", + "spellcheck.collateExtendedResults":"true", + "spellcheck.maxCollationTries":"10", + "spellcheck.maxCollations":"5"}, + "last-components":["spellcheck"]}, + "/terms":{ + "startup":"lazy", + "name":"/terms", + "class":"solr.SearchHandler", + "defaults":{ + "terms":true, + "distrib":false}, + "components":["terms"]}, + "/update":{ + "useParams":"_UPDATE", + "class":"solr.UpdateRequestHandler", + "name":"/update"}, + "/update/json":{ + "useParams":"_UPDATE_JSON", + "class":"solr.UpdateRequestHandler", + "invariants":{"update.contentType":"application/json"}, + "name":"/update/json"}, + "/update/csv":{ + "useParams":"_UPDATE_CSV", + "class":"solr.UpdateRequestHandler", + "invariants":{"update.contentType":"application/csv"}, + "name":"/update/csv"}, + "/update/json/docs":{ + "useParams":"_UPDATE_JSON_DOCS", + "class":"solr.UpdateRequestHandler", + "invariants":{ + "update.contentType":"application/json", + "json.command":"false"}, + "name":"/update/json/docs"}, + "update":{ + "class":"solr.UpdateRequestHandlerApi", + "useParams":"_UPDATE_JSON_DOCS", + "name":"update"}, + "/config":{ + "useParams":"_CONFIG", + "class":"solr.SolrConfigHandler", + "name":"/config"}, + "/schema":{ + "class":"solr.SchemaHandler", + "useParams":"_SCHEMA", + "name":"/schema"}, + "/replication":{ + "class":"solr.ReplicationHandler", + "useParams":"_REPLICATION", + "name":"/replication"}, + "/get":{ + "class":"solr.RealTimeGetHandler", + "useParams":"_GET", + "defaults":{ + "omitHeader":true, + "wt":"json", + "indent":true}, + "name":"/get"}, + "/admin/ping":{ + "class":"solr.PingRequestHandler", + "useParams":"_ADMIN_PING", + "invariants":{ + "echoParams":"all", + "q":"{!lucene}*:*"}, + "name":"/admin/ping"}, + "/admin/segments":{ + "class":"solr.SegmentsInfoRequestHandler", + "useParams":"_ADMIN_SEGMENTS", + "name":"/admin/segments"}, + "/admin/luke":{ + "class":"solr.LukeRequestHandler", + "useParams":"_ADMIN_LUKE", + "name":"/admin/luke"}, + "/admin/system":{ + "class":"solr.SystemInfoHandler", + "useParams":"_ADMIN_SYSTEM", + "name":"/admin/system"}, + "/admin/mbeans":{ + "class":"solr.SolrInfoMBeanHandler", + "useParams":"_ADMIN_MBEANS", + "name":"/admin/mbeans"}, + "/admin/plugins":{ + "class":"solr.PluginInfoHandler", + "name":"/admin/plugins"}, + "/admin/threads":{ + "class":"solr.ThreadDumpHandler", + "useParams":"_ADMIN_THREADS", + "name":"/admin/threads"}, + "/admin/properties":{ + "class":"solr.PropertiesRequestHandler", + "useParams":"_ADMIN_PROPERTIES", + "name":"/admin/properties"}, + "/admin/logging":{ + "class":"solr.LoggingHandler", + "useParams":"_ADMIN_LOGGING", + "name":"/admin/logging"}, + "/admin/health":{ + "class":"solr.HealthCheckHandler", + "useParams":"_ADMIN_HEALTH", + "name":"/admin/health"}, + "/admin/file":{ + "class":"solr.ShowFileRequestHandler", + "useParams":"_ADMIN_FILE", + "name":"/admin/file"}, + "/export":{ + "class":"solr.ExportHandler", + "useParams":"_EXPORT", + "components":["query"], + "defaults":{"wt":"json"}, + "invariants":{ + "rq":"{!xport}", + "distrib":false}, + "name":"/export"}, + "/graph":{ + "class":"solr.GraphHandler", + "useParams":"_ADMIN_GRAPH", + "invariants":{ + "wt":"graphml", + "distrib":false}, + "name":"/graph"}, + "/stream":{ + "class":"solr.StreamHandler", + "useParams":"_STREAM", + "defaults":{"wt":"json"}, + "invariants":{"distrib":false}, + "name":"/stream"}, + "/sql":{ + "class":"solr.SQLHandler", + "useParams":"_SQL", + "defaults":{"wt":"json"}, + "invariants":{"distrib":false}, + "name":"/sql"}, + "/analysis/document":{ + "class":"solr.DocumentAnalysisRequestHandler", + "startup":"lazy", + "useParams":"_ANALYSIS_DOCUMENT", + "name":"/analysis/document"}, + "/analysis/field":{ + "class":"solr.FieldAnalysisRequestHandler", + "startup":"lazy", + "useParams":"_ANALYSIS_FIELD", + "name":"/analysis/field"}, + "/debug/dump":{ + "class":"solr.DumpRequestHandler", + "useParams":"_DEBUG_DUMP", + "defaults":{ + "echoParams":"explicit", + "echoHandler":true}, + "name":"/debug/dump"}}, + "queryResponseWriter":{"json":{ + "name":"json", + "class":"solr.JSONResponseWriter", + "content-type":"text/plain; charset=UTF-8"}}, + "searchComponent":{ + "spellcheck":{ + "name":"spellcheck", + "class":"solr.SpellCheckComponent", + "queryAnalyzerFieldType":"text_general", + "spellchecker":{ + "name":"default", + "field":"_text_", + "classname":"solr.DirectSolrSpellChecker", + "distanceMeasure":"internal", + "accuracy":0.5, + "maxEdits":2, + "minPrefix":1, + "maxInspections":5, + "minQueryLength":4, + "maxQueryFrequency":0.01}}, + "terms":{ + "name":"terms", + "class":"solr.TermsComponent"}}, + "updateProcessor":{ + "uuid":{ + "name":"uuid", + "class":"solr.UUIDUpdateProcessorFactory"}, + "remove-blank":{ + "name":"remove-blank", + "class":"solr.RemoveBlankFieldUpdateProcessorFactory"}, + "field-name-mutating":{ + "name":"field-name-mutating", + "class":"solr.FieldNameMutatingUpdateProcessorFactory", + "pattern":"[^\\w-\\.]", + "replacement":"_"}, + "parse-boolean":{ + "name":"parse-boolean", + "class":"solr.ParseBooleanFieldUpdateProcessorFactory"}, + "parse-long":{ + "name":"parse-long", + "class":"solr.ParseLongFieldUpdateProcessorFactory"}, + "parse-double":{ + "name":"parse-double", + "class":"solr.ParseDoubleFieldUpdateProcessorFactory"}, + "parse-date":{ + "name":"parse-date", + "class":"solr.ParseDateFieldUpdateProcessorFactory"}, + "add-schema-fields":{ + "name":"add-schema-fields", + "class":"solr.AddSchemaFieldsUpdateProcessorFactory"}}, + "initParams":[{ + "path":"/update/**,/query,/select,/spell", + "defaults":{"df":"_text_"}}], + "listener":[{ + "event":"newSearcher", + "class":"solr.QuerySenderListener", + "queries":[]}, + { + "event":"firstSearcher", + "class":"solr.QuerySenderListener", + "queries":[]}], + "directoryFactory":{ + "name":"DirectoryFactory", + "class":"solr.NRTCachingDirectoryFactory"}, + "codecFactory":{"class":"solr.SchemaCodecFactory"}, + "updateRequestProcessorChain":[{ + "default":"true", + "name":"add-unknown-fields-to-the-schema", + "processor":"uuid,remove-blank,field-name-mutating,parse-boolean,parse-long,parse-double,parse-date,add-schema-fields", + "":[{"class":"solr.LogUpdateProcessorFactory"}, + {"class":"solr.DistributedUpdateProcessorFactory"}, + {"class":"solr.RunUpdateProcessorFactory"}]}], + "updateHandlerupdateLog":{ + "dir":"", + "numVersionBuckets":65536}, + "requestDispatcher":{ + "handleSelect":false, + "httpCaching":{ + "never304":true, + "etagSeed":"Solr", + "lastModFrom":"opentime", + "cacheControl":null}, + "requestParsers":{ + "multipartUploadLimitKB":2147483647, + "formUploadLimitKB":2147483647, + "addHttpRequestToContext":false}}, + "indexConfig":{ + "useCompoundFile":false, + "maxBufferedDocs":-1, + "ramBufferSizeMB":100.0, + "ramPerThreadHardLimitMB":-1, + "writeLockTimeout":-1, + "lockType":"native", + "infoStreamEnabled":false, + "metrics":{}}, + "peerSync":{"useRangeVersions":true}}} + headers: + Content-Length: + - "10311" + Content-Security-Policy: + - default-src 'none'; base-uri 'none'; connect-src 'self'; form-action 'self'; font-src 'self'; frame-ancestors 'none'; img-src 'self'; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self'; + Content-Type: + - text/plain;charset=utf-8 + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" diff --git a/config/fixtures/send-commands-error.yaml b/config/fixtures/send-commands-error.yaml new file mode 100644 index 0000000..d23dc3b --- /dev/null +++ b/config/fixtures/send-commands-error.yaml @@ -0,0 +1,52 @@ +--- +version: 1 +interactions: +- request: + body: '{"add-searchcomponent": {"class":"solr.SuggestComponent","name":"suggest","suggester":{"dictionaryImpl":"DocumentDictionaryFactory-BLAH-BLAH","field":"_text_","lookupImpl":"FuzzyLookupFactory-BLAH-BLAH","name":"mySuggester","suggestAnalyzerFieldType":"text_general"}}}' + form: {} + headers: + Content-Type: + - application/json + url: http://localhost:8983/solr/gettingstarted/config + method: POST + response: + body: | + { + "responseHeader":{ + "status":400, + "QTime":5}, + "errorMessages":["error processing commands\n"], + "WARNING":"This response format is experimental. It is likely to change in the future.", + "error":{ + "metadata":[ + "error-class","org.apache.solr.api.ApiBag$ExceptionWithErrObject", + "root-error-class","org.apache.solr.api.ApiBag$ExceptionWithErrObject"], + "details":[{ + "add-searchcomponent":{ + "class":"solr.SuggestComponent", + "name":"suggest", + "suggester":{ + "dictionaryImpl":"DocumentDictionaryFactory-BLAH-BLAH", + "field":"_text_", + "lookupImpl":"FuzzyLookupFactory-BLAH-BLAH", + "name":"mySuggester", + "suggestAnalyzerFieldType":"text_general"}}, + "errorMessages":[" 'suggest' already exists . Do an 'update-searchcomponent' , if you want to change it "]}], + "msg":"error processing commands", + "code":400}} + headers: + Content-Length: + - "945" + Content-Security-Policy: + - default-src 'none'; base-uri 'none'; connect-src 'self'; form-action 'self'; font-src 'self'; frame-ancestors 'none'; img-src 'self'; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self'; + Content-Type: + - text/plain;charset=utf-8 + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + status: 400 Bad Request + code: 400 + duration: "" diff --git a/config/fixtures/send-commands-ok.yaml b/config/fixtures/send-commands-ok.yaml new file mode 100644 index 0000000..f387861 --- /dev/null +++ b/config/fixtures/send-commands-ok.yaml @@ -0,0 +1,34 @@ +--- +version: 1 +interactions: +- request: + body: '{"set-property": {"updateHandler.autoCommit.maxTime":15000},"add-searchcomponent": {"class":"solr.SuggestComponent","name":"suggest","suggester":{"dictionaryImpl":"DocumentDictionaryFactory","field":"_text_","lookupImpl":"FuzzyLookupFactory","name":"mySuggester","suggestAnalyzerFieldType":"text_general"}},"add-requesthandler": {"class":"solr.SearchHandler","components":["suggest"],"defaults":{"suggest":true,"suggest.count":10,"suggest.dictionary":"mySuggester"},"name":"/suggest","startup":"lazy"}}' + form: {} + headers: + Content-Type: + - application/json + url: http://localhost:8983/solr/gettingstarted/config + method: POST + response: + body: | + { + "responseHeader":{ + "status":0, + "QTime":836}, + "WARNING":"This response format is experimental. It is likely to change in the future."} + headers: + Content-Length: + - "149" + Content-Security-Policy: + - default-src 'none'; base-uri 'none'; connect-src 'self'; form-action 'self'; font-src 'self'; frame-ancestors 'none'; img-src 'self'; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self'; + Content-Type: + - text/plain;charset=utf-8 + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" diff --git a/config/response.go b/config/response.go new file mode 100644 index 0000000..1295a17 --- /dev/null +++ b/config/response.go @@ -0,0 +1,101 @@ +package config + +import ( + "fmt" + "reflect" + "strings" +) + +// Response is a config response +type Response struct { + ResponseHeader *ResponseHeader `json:"responseHeader,omitempty"` + Config *Config `json:"config,omitempty"` + Error *Error `json:"error,omitempty"` +} + +// ResponseHeader is a response header +type ResponseHeader struct { + Status int `json:"status"` + QTime int `json:"QTime"` +} + +// Config response body +type Config struct { + LuceneMatchVersion string `json:"luceneMatcheVersion"` + UpdateHandler map[string]interface{} `json:"updateHandler,omitempty"` + Query map[string]interface{} `json:"query,omitempty"` + RequestHandler map[string]interface{} `json:"requestHandler,omitempty"` + QueryResponseWriter map[string]interface{} `json:"queryResponseWriter,omitempty"` + SearchComponent map[string]interface{} `json:"searchComponent,omitempty"` + UpdateProcessor map[string]interface{} `json:"updateProcessor,omitempty"` + InitParams []interface{} `json:"initParams,omitempty"` + Listener []interface{} `json:"listener,omitempty"` + DirectoryFactory map[string]interface{} `json:"directoryFactory,omitempty"` + CodeFactory map[string]interface{} `json:"codeFactory,omitempty"` + UpdateRequestProcessorChain []interface{} `json:"updateRequestProcessorChain,omitempty"` + UpdateHandlerUpdateLog map[string]interface{} `json:"updateHandlerupdateLog,omitempty"` + RequestDispatcher map[string]interface{} `json:"requestDispatcher,omitempty"` + IndexConfig map[string]interface{} `json:"indexCofig,omitempty"` + PeerSync map[string]interface{} `json:"peerSync,omitempty"` +} + +// Error is the response error detail +type Error struct { + Code int `json:"code"` + Details []struct { + command + + ErrorMessages []string `json:"errorMessages"` + } `json:"details"` + Metadata []string `json:"metadata"` + Msg string `json:"msg"` +} + +type command struct { + AddRequestHandler interface{} `json:"add-requesthandler"` + UpdateRequestHandler interface{} `json:"update-requesthandler"` + DeleteRequestHandler interface{} `json:"delete-requesthandler"` + AddSearchComponent interface{} `json:"add-searchcomponent"` + UpdateSearchComponent interface{} `json:"update-searchcomponent"` + DeleteSearchComponent interface{} `json:"delete-searchcomponent"` + AddInitParams interface{} `json:"add-initparams"` + UpdateInitParams interface{} `json:"update-initparams"` + DeleteInitParams interface{} `json:"delete-initparams"` + AddQueryResponseWriter interface{} `json:"add-queryresponsewriter"` + UpdateQueryResponseWriter interface{} `json:"update-queryresponsewriter"` + DeleteQueryResponseWriter interface{} `json:"delete-queryresponsewriter"` +} + +func (c command) getCmd() string { + cv := reflect.ValueOf(c) + for i := 0; i < cv.NumField(); i++ { + cf := cv.Field(i) + if cf.IsZero() { + continue + } + + tagVal := cv.Type().Field(i).Tag.Get("json") + args := strings.Split(tagVal, ",") + return args[0] + } + + return "unknown" +} + +func (e Error) Error() string { + errMsgs := []string{} + + for _, det := range e.Details { + + errs := []string{} + for _, errMsg := range det.ErrorMessages { + errs = append(errs, errMsg) + } + + errMsgs = append(errMsgs, fmt.Sprintf("%s: %s", + det.getCmd(), strings.Join(errs, ", "))) + + } + + return fmt.Sprintf("%s: %s", e.Msg, strings.Join(errMsgs, ", ")) +} diff --git a/config/response_test.go b/config/response_test.go new file mode 100644 index 0000000..c2cb433 --- /dev/null +++ b/config/response_test.go @@ -0,0 +1,61 @@ +package config + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestError_Error(t *testing.T) { + tests := []struct { + name string + respErr []byte + want string + }{ + { + name: "add search component error", + respErr: []byte(`{ + "metadata":[ + "error-class","org.apache.solr.api.ApiBag$ExceptionWithErrObject", + "root-error-class","org.apache.solr.api.ApiBag$ExceptionWithErrObject"], + "details":[{ + "add-searchcomponent":{ + "class":"solr.SuggestComponent", + "name":"suggest", + "suggester":{ + "dictionaryImpl":"DocumentDictionaryFactory-BLAH-BLAH", + "field":"_text_", + "lookupImpl":"FuzzyLookupFactory-BLAH-BLAH", + "name":"mySuggester", + "suggestAnalyzerFieldType":"text_general"}}, + "errorMessages":[" 'suggest' already exists . Do an 'update-searchcomponent' , if you want to change it "]}], + "msg":"error processing commands", + "code":400}`), + want: "error processing commands: add-searchcomponent: 'suggest' already exists . Do an 'update-searchcomponent' , if you want to change it ", + }, + { + name: "unknown command", + respErr: []byte(`{ + "details":[{ + "add-queryparser":{}, + "errorMessages":["an error"]}], + "msg":"error processing commands", + "code":400}`), + want: "error processing commands: unknown: an error", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := assert.New(t) + + var e Error + err := json.Unmarshal(tt.respErr, &e) + require.NoError(t, err) + + got := e.Error() + a.Equal(tt.want, got) + }) + } +} diff --git a/index/doc.go b/index/doc.go new file mode 100644 index 0000000..34c397a --- /dev/null +++ b/index/doc.go @@ -0,0 +1,2 @@ +// Package index contains tools related for interacting with index API +package index diff --git a/query/doc.go b/query/doc.go new file mode 100644 index 0000000..1120d4d --- /dev/null +++ b/query/doc.go @@ -0,0 +1,2 @@ +// Package query contains tools for interacting with query API +package query diff --git a/schema/doc.go b/schema/doc.go new file mode 100644 index 0000000..136b808 --- /dev/null +++ b/schema/doc.go @@ -0,0 +1,2 @@ +// Package schema contains tools for interacting with schema API +package schema diff --git a/suggester/doc.go b/suggester/doc.go new file mode 100644 index 0000000..cad1d76 --- /dev/null +++ b/suggester/doc.go @@ -0,0 +1,2 @@ +// Package suggester contains tools for interacting with auto-suggest API +package suggester