diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index e447225..f39a791 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -32,23 +32,48 @@ jobs: fi sleep 1 done - # Create database and setup schema + - name: Setup Database run: | - # Create the test database docker exec gecko-postgres-test psql -U postgres -c "CREATE DATABASE testdb;" - # Create the table docker exec gecko-postgres-test psql -U postgres -d testdb -c " - CREATE TABLE IF NOT EXISTS documents ( - name VARCHAR(255) PRIMARY KEY, - content JSONB - );" + DO \$\$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'config_schema') THEN + CREATE SCHEMA config_schema; + END IF; + END \$\$; + + CREATE OR REPLACE FUNCTION create_config_table(schema_name TEXT, table_name TEXT) + RETURNS void AS \$\$ + BEGIN + EXECUTE format(' + CREATE TABLE IF NOT EXISTS %I.%I ( + name VARCHAR(255) PRIMARY KEY, + content JSONB + ); + ', schema_name, table_name); + END; + \$\$ LANGUAGE plpgsql; + + DO \$\$ + DECLARE + config_tables TEXT[] := ARRAY['explorer', 'nav', 'file_summary', 'apps_page']; + table_name TEXT; + BEGIN + FOREACH table_name IN ARRAY config_tables + LOOP + PERFORM create_config_table('config_schema', table_name); + END LOOP; + END \$\$; + + DROP FUNCTION create_config_table(TEXT, TEXT); + " env: PGPASSWORD: your_strong_password - - name: Start Qdrant run: | docker rm -f gecko-qdrant-test > /dev/null 2>&1 || true @@ -68,10 +93,16 @@ jobs: done - - name: Start Application + - name: Run Tests run: | make - ./bin/gecko -db "postgresql://postgres:your_strong_password@localhost:8081/testdb?sslmode=disable" -port 8080 -qdrant-api-key "your_qdrant_api_key" -qdrant-host localhost -qdrant-port 6334 & + ./bin/gecko \ + -db "postgresql://postgres:your_strong_password@localhost:8081/testdb?sslmode=disable" \ + -port 8080 \ + -qdrant-api-key "your_qdrant_api_key" \ + -qdrant-host localhost \ + -qdrant-port 6334 & + # Wait for application to be ready for i in {1..30}; do if curl --silent --fail http://localhost:8080/health > /dev/null; then break diff --git a/docs/docs.go b/docs/docs.go index 7194cb8..6b05dee 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -17,7 +17,7 @@ const docTemplate = `{ "paths": { "/config/list": { "get": { - "description": "Retrieve a list of all available configurations", + "description": "Retrieve a list of all available configuration IDs for the given type (table).", "consumes": [ "application/json" ], @@ -27,19 +27,28 @@ const docTemplate = `{ "tags": [ "Config" ], - "summary": "List all configurations", + "summary": "List all configuration IDs for a specific type", + "parameters": [ + { + "type": "string", + "description": "Configuration Type (table name)", + "name": "configType", + "in": "path", + "required": true + } + ], "responses": { "200": { - "description": "OK", + "description": "List of config IDs", "schema": { "type": "array", "items": { - "$ref": "#/definitions/config.Config" + "type": "string" } } }, "404": { - "description": "No configs found", + "description": "No configs found for this type", "schema": { "$ref": "#/definitions/gecko.ErrorResponse" } @@ -53,9 +62,9 @@ const docTemplate = `{ } } }, - "/config/{configId}": { + "/config/{configType}/{configId}": { "get": { - "description": "Retrieve configuration by ID", + "description": "Retrieve configuration by configType and configId", "produces": [ "application/json" ], @@ -64,6 +73,13 @@ const docTemplate = `{ ], "summary": "Get a specific configuration", "parameters": [ + { + "type": "string", + "description": "Configuration Type (table name)", + "name": "configType", + "in": "path", + "required": true + }, { "type": "string", "description": "Configuration ID", @@ -94,7 +110,7 @@ const docTemplate = `{ } }, "put": { - "description": "Replaces or updates the configuration items for a given config ID", + "description": "Replaces or updates the configuration items for a given config ID in a specific type (table)", "consumes": [ "application/json" ], @@ -106,6 +122,13 @@ const docTemplate = `{ ], "summary": "Update configuration", "parameters": [ + { + "type": "string", + "description": "Configuration Type (table name)", + "name": "configType", + "in": "path", + "required": true + }, { "type": "string", "description": "Configuration ID", @@ -136,12 +159,6 @@ const docTemplate = `{ "$ref": "#/definitions/gecko.ErrorResponse" } }, - "404": { - "description": "Config not found", - "schema": { - "$ref": "#/definitions/gecko.ErrorResponse" - } - }, "500": { "description": "Internal server error", "schema": { @@ -151,7 +168,7 @@ const docTemplate = `{ } }, "delete": { - "description": "Delete configuration by ID", + "description": "Delete configuration by configType and configId", "produces": [ "application/json" ], @@ -160,6 +177,13 @@ const docTemplate = `{ ], "summary": "Delete a configuration", "parameters": [ + { + "type": "string", + "description": "Configuration Type (table name)", + "name": "configType", + "in": "path", + "required": true + }, { "type": "string", "description": "Configuration ID", @@ -191,6 +215,61 @@ const docTemplate = `{ } } }, + "/dir/{projectId}": { + "get": { + "description": "Retrieve directory details for the given project ID and Directory path", + "produces": [ + "application/json" + ], + "tags": [ + "Directory" + ], + "summary": "Retrieve directory information for a project", + "parameters": [ + { + "type": "string", + "description": "Project ID (format: program-project)", + "name": "projectId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Directory Path (e.g., /data/my-dir)", + "name": "directory_path", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "Directory information", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Invalid request body or Directory path", + "schema": { + "$ref": "#/definitions/gecko.ErrorResponse" + } + }, + "403": { + "description": "User is not allowed on any resource path", + "schema": { + "$ref": "#/definitions/gecko.ErrorResponse" + } + }, + "500": { + "description": "Server error", + "schema": { + "$ref": "#/definitions/gecko.ErrorResponse" + } + } + } + } + }, "/health": { "get": { "description": "Checks the database connection and returns the server status", @@ -825,7 +904,7 @@ const docTemplate = `{ }, "payload": { "type": "object", - "additionalProperties": true + "additionalProperties": {} }, "score": { "type": "number" @@ -833,7 +912,7 @@ const docTemplate = `{ "vectors": { "description": "can’t type vector length", "type": "object", - "additionalProperties": true + "additionalProperties": {} } } }, @@ -1133,14 +1212,57 @@ const docTemplate = `{ } } }, + "config.SummaryTableColumnType": { + "type": "string", + "enum": [ + "string", + "number", + "date", + "array", + "link", + "boolean", + "paragraphs" + ], + "x-enum-varnames": [ + "SummaryTableColumnTypeString", + "SummaryTableColumnTypeNumber", + "SummaryTableColumnTypeDate", + "SummaryTableColumnTypeArray", + "SummaryTableColumnTypeLink", + "SummaryTableColumnTypeBoolean", + "SummaryTableColumnTypeParagraphs" + ] + }, "config.TableColumnsConfig": { "type": "object", "properties": { + "accessorPath": { + "type": "string" + }, + "cellRenderFunction": { + "type": "string" + }, "field": { "type": "string" }, + "params": { + "type": "object", + "additionalProperties": {} + }, + "sortable": { + "type": "boolean" + }, "title": { "type": "string" + }, + "type": { + "$ref": "#/definitions/config.SummaryTableColumnType" + }, + "visable": { + "type": "boolean" + }, + "width": { + "type": "string" } } }, diff --git a/docs/swagger.json b/docs/swagger.json index 994deb0..be01477 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -11,7 +11,7 @@ "paths": { "/config/list": { "get": { - "description": "Retrieve a list of all available configurations", + "description": "Retrieve a list of all available configuration IDs for the given type (table).", "consumes": [ "application/json" ], @@ -21,19 +21,28 @@ "tags": [ "Config" ], - "summary": "List all configurations", + "summary": "List all configuration IDs for a specific type", + "parameters": [ + { + "type": "string", + "description": "Configuration Type (table name)", + "name": "configType", + "in": "path", + "required": true + } + ], "responses": { "200": { - "description": "OK", + "description": "List of config IDs", "schema": { "type": "array", "items": { - "$ref": "#/definitions/config.Config" + "type": "string" } } }, "404": { - "description": "No configs found", + "description": "No configs found for this type", "schema": { "$ref": "#/definitions/gecko.ErrorResponse" } @@ -47,9 +56,9 @@ } } }, - "/config/{configId}": { + "/config/{configType}/{configId}": { "get": { - "description": "Retrieve configuration by ID", + "description": "Retrieve configuration by configType and configId", "produces": [ "application/json" ], @@ -58,6 +67,13 @@ ], "summary": "Get a specific configuration", "parameters": [ + { + "type": "string", + "description": "Configuration Type (table name)", + "name": "configType", + "in": "path", + "required": true + }, { "type": "string", "description": "Configuration ID", @@ -88,7 +104,7 @@ } }, "put": { - "description": "Replaces or updates the configuration items for a given config ID", + "description": "Replaces or updates the configuration items for a given config ID in a specific type (table)", "consumes": [ "application/json" ], @@ -100,6 +116,13 @@ ], "summary": "Update configuration", "parameters": [ + { + "type": "string", + "description": "Configuration Type (table name)", + "name": "configType", + "in": "path", + "required": true + }, { "type": "string", "description": "Configuration ID", @@ -130,12 +153,6 @@ "$ref": "#/definitions/gecko.ErrorResponse" } }, - "404": { - "description": "Config not found", - "schema": { - "$ref": "#/definitions/gecko.ErrorResponse" - } - }, "500": { "description": "Internal server error", "schema": { @@ -145,7 +162,7 @@ } }, "delete": { - "description": "Delete configuration by ID", + "description": "Delete configuration by configType and configId", "produces": [ "application/json" ], @@ -154,6 +171,13 @@ ], "summary": "Delete a configuration", "parameters": [ + { + "type": "string", + "description": "Configuration Type (table name)", + "name": "configType", + "in": "path", + "required": true + }, { "type": "string", "description": "Configuration ID", @@ -185,6 +209,61 @@ } } }, + "/dir/{projectId}": { + "get": { + "description": "Retrieve directory details for the given project ID and Directory path", + "produces": [ + "application/json" + ], + "tags": [ + "Directory" + ], + "summary": "Retrieve directory information for a project", + "parameters": [ + { + "type": "string", + "description": "Project ID (format: program-project)", + "name": "projectId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Directory Path (e.g., /data/my-dir)", + "name": "directory_path", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "Directory information", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Invalid request body or Directory path", + "schema": { + "$ref": "#/definitions/gecko.ErrorResponse" + } + }, + "403": { + "description": "User is not allowed on any resource path", + "schema": { + "$ref": "#/definitions/gecko.ErrorResponse" + } + }, + "500": { + "description": "Server error", + "schema": { + "$ref": "#/definitions/gecko.ErrorResponse" + } + } + } + } + }, "/health": { "get": { "description": "Checks the database connection and returns the server status", @@ -819,7 +898,7 @@ }, "payload": { "type": "object", - "additionalProperties": true + "additionalProperties": {} }, "score": { "type": "number" @@ -827,7 +906,7 @@ "vectors": { "description": "can’t type vector length", "type": "object", - "additionalProperties": true + "additionalProperties": {} } } }, @@ -1127,14 +1206,57 @@ } } }, + "config.SummaryTableColumnType": { + "type": "string", + "enum": [ + "string", + "number", + "date", + "array", + "link", + "boolean", + "paragraphs" + ], + "x-enum-varnames": [ + "SummaryTableColumnTypeString", + "SummaryTableColumnTypeNumber", + "SummaryTableColumnTypeDate", + "SummaryTableColumnTypeArray", + "SummaryTableColumnTypeLink", + "SummaryTableColumnTypeBoolean", + "SummaryTableColumnTypeParagraphs" + ] + }, "config.TableColumnsConfig": { "type": "object", "properties": { + "accessorPath": { + "type": "string" + }, + "cellRenderFunction": { + "type": "string" + }, "field": { "type": "string" }, + "params": { + "type": "object", + "additionalProperties": {} + }, + "sortable": { + "type": "boolean" + }, "title": { "type": "string" + }, + "type": { + "$ref": "#/definitions/config.SummaryTableColumnType" + }, + "visable": { + "type": "boolean" + }, + "width": { + "type": "string" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index afd18b0..337884f 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -102,12 +102,12 @@ definitions: id: type: string payload: - additionalProperties: true + additionalProperties: {} type: object score: type: number vectors: - additionalProperties: true + additionalProperties: {} description: can’t type vector length type: object type: object @@ -303,12 +303,45 @@ definitions: type: array type: object type: object + config.SummaryTableColumnType: + enum: + - string + - number + - date + - array + - link + - boolean + - paragraphs + type: string + x-enum-varnames: + - SummaryTableColumnTypeString + - SummaryTableColumnTypeNumber + - SummaryTableColumnTypeDate + - SummaryTableColumnTypeArray + - SummaryTableColumnTypeLink + - SummaryTableColumnTypeBoolean + - SummaryTableColumnTypeParagraphs config.TableColumnsConfig: properties: + accessorPath: + type: string + cellRenderFunction: + type: string field: type: string + params: + additionalProperties: {} + type: object + sortable: + type: boolean title: type: string + type: + $ref: '#/definitions/config.SummaryTableColumnType' + visable: + type: boolean + width: + type: string type: object config.TableConfig: properties: @@ -659,10 +692,15 @@ info: title: Gecko API version: 1.0.0 paths: - /config/{configId}: + /config/{configType}/{configId}: delete: - description: Delete configuration by ID + description: Delete configuration by configType and configId parameters: + - description: Configuration Type (table name) + in: path + name: configType + required: true + type: string - description: Configuration ID in: path name: configId @@ -688,8 +726,13 @@ paths: tags: - Config get: - description: Retrieve configuration by ID + description: Retrieve configuration by configType and configId parameters: + - description: Configuration Type (table name) + in: path + name: configType + required: true + type: string - description: Configuration ID in: path name: configId @@ -717,8 +760,13 @@ paths: consumes: - application/json description: Replaces or updates the configuration items for a given config - ID + ID in a specific type (table) parameters: + - description: Configuration Type (table name) + in: path + name: configType + required: true + type: string - description: Configuration ID in: path name: configId @@ -741,10 +789,6 @@ paths: description: Invalid request body schema: $ref: '#/definitions/gecko.ErrorResponse' - "404": - description: Config not found - schema: - $ref: '#/definitions/gecko.ErrorResponse' "500": description: Internal server error schema: @@ -756,27 +800,72 @@ paths: get: consumes: - application/json - description: Retrieve a list of all available configurations + description: Retrieve a list of all available configuration IDs for the given + type (table). + parameters: + - description: Configuration Type (table name) + in: path + name: configType + required: true + type: string produces: - application/json responses: "200": - description: OK + description: List of config IDs schema: items: - $ref: '#/definitions/config.Config' + type: string type: array "404": - description: No configs found + description: No configs found for this type schema: $ref: '#/definitions/gecko.ErrorResponse' "500": description: Server error schema: $ref: '#/definitions/gecko.ErrorResponse' - summary: List all configurations + summary: List all configuration IDs for a specific type tags: - Config + /dir/{projectId}: + get: + description: Retrieve directory details for the given project ID and Directory + path + parameters: + - description: 'Project ID (format: program-project)' + in: path + name: projectId + required: true + type: string + - description: Directory Path (e.g., /data/my-dir) + in: query + name: directory_path + required: true + type: string + produces: + - application/json + responses: + "200": + description: Directory information + schema: + additionalProperties: true + type: object + "400": + description: Invalid request body or Directory path + schema: + $ref: '#/definitions/gecko.ErrorResponse' + "403": + description: User is not allowed on any resource path + schema: + $ref: '#/definitions/gecko.ErrorResponse' + "500": + description: Server error + schema: + $ref: '#/definitions/gecko.ErrorResponse' + summary: Retrieve directory information for a project + tags: + - Directory /health: get: description: Checks the database connection and returns the server status diff --git a/gecko/adapter/types.go b/gecko/adapter/types.go index 08dee4d..1c86549 100644 --- a/gecko/adapter/types.go +++ b/gecko/adapter/types.go @@ -41,7 +41,7 @@ type CreateCollectionRequest struct { // @Schema type DeletePoints struct { Points []string `json:"points"` - Filter HeadFilter `json:"filter,omitempty"` + Filter HeadFilter `json:"filter"` } // QueryPointsRequest represents a Qdrant query request @@ -85,10 +85,10 @@ type QueryPointsRequest struct { // QueryPointsResponseItem represents one point in the query response // @Description Simplified Qdrant point response type QueryPointsResponseItem struct { - ID string `json:"id"` - Score float32 `json:"score"` - Vectors map[string]interface{} `json:"vectors,omitempty"` // can’t type vector length - Payload map[string]interface{} `json:"payload,omitempty"` + ID string `json:"id"` + Score float32 `json:"score"` + Vectors map[string]any `json:"vectors,omitempty"` // can’t type vector length + Payload map[string]any `json:"payload,omitempty"` } type SearchParamsRequest struct { diff --git a/gecko/config/appsPageConfig.go b/gecko/config/appsPageConfig.go new file mode 100644 index 0000000..4fd95a3 --- /dev/null +++ b/gecko/config/appsPageConfig.go @@ -0,0 +1,13 @@ +package config + +type AppCard struct { + Title string `json:"title"` + Description string `json:"description"` + Icon string `json:"icon"` + Href string `json:"href"` + Perms string `json:"perms"` +} + +type AppsConfig struct { + AppCards []AppCard `json:"appCards"` +} diff --git a/gecko/config/configurable.go b/gecko/config/configurable.go new file mode 100644 index 0000000..a3ea9ea --- /dev/null +++ b/gecko/config/configurable.go @@ -0,0 +1,26 @@ +package config + +type Configurable interface { + IsZero() bool +} + +// Update config.Config to implement the interface +func (c Config) IsZero() bool { + return len(c.ExplorerConfig) == 0 +} + +func (ap AppsConfig) IsZero() bool { + return len(ap.AppCards) == 0 +} + +func (n NavPageLayoutProps) IsZero() bool { + return len(n.HeaderProps.LeftNav) == 0 && + len(n.FooterProps.RightSection.Columns) == 0 && + len(n.FooterProps.BottomLinks) == 0 && + len(n.FooterProps.ColumnLinks) == 0 && + n.FooterProps.RightSection == nil +} + +func (fs FilesummaryConfig) IsZero() bool { + return len(fs.Config) == 0 +} diff --git a/gecko/config/explorerConfig.go b/gecko/config/explorerConfig.go index ef0ad26..8215974 100644 --- a/gecko/config/explorerConfig.go +++ b/gecko/config/explorerConfig.go @@ -1,5 +1,12 @@ package config +import "encoding/json" + +type Reports struct { + Title string `json:"Title"` + TableConfig TableConfig `json:"tableConfig"` +} + type FieldConfig struct { Field string `json:"field,omitempty"` DataField string `json:"dataField,omitempty"` @@ -22,12 +29,54 @@ type TableConfig struct { Enabled bool `json:"enabled"` Fields []string `json:"fields"` Columns map[string]TableColumnsConfig `json:"columns,omitempty"` - DetailsConfig TableDetailsConfig `json:"detailsConfig,omitempty"` -} + DetailsConfig TableDetailsConfig `json:"detailsConfig"` +} + +type SummaryTableColumnType string + +// Constants define the allowed values for SummaryTableColumnType. +const ( + // SummaryTableColumnTypeString represents a string column type. + SummaryTableColumnTypeString SummaryTableColumnType = "string" + // SummaryTableColumnTypeNumber represents a number column type. + SummaryTableColumnTypeNumber SummaryTableColumnType = "number" + // SummaryTableColumnTypeDate represents a date column type. + SummaryTableColumnTypeDate SummaryTableColumnType = "date" + // SummaryTableColumnTypeArray represents an array column type. + SummaryTableColumnTypeArray SummaryTableColumnType = "array" + // SummaryTableColumnTypeLink represents a link column type. + SummaryTableColumnTypeLink SummaryTableColumnType = "link" + // SummaryTableColumnTypeBoolean represents a boolean column type. + SummaryTableColumnTypeBoolean SummaryTableColumnType = "boolean" + // SummaryTableColumnTypeParagraphs represents a paragraphs column type. + SummaryTableColumnTypeParagraphs SummaryTableColumnType = "paragraphs" +) type TableColumnsConfig struct { - Field string `json:"field"` - Title string `json:"title"` + Field string `json:"field"` + Title string `json:"title"` + AccessorPath string `json:"accessorPath,omitempty"` + Type SummaryTableColumnType `json:"type,omitempty"` + CellRenderFunction string `json:"cellRenderFunction,omitempty"` + Params map[string]any `json:"params,omitempty"` + Width string `json:"width,omitempty"` + Sortable bool `json:"sortable,omitempty"` + Visable bool `json:"visable,omitempty"` +} + +// MarshalJSON omits "type" field when it's zero value +func (t TableColumnsConfig) MarshalJSON() ([]byte, error) { + type Alias TableColumnsConfig + aux := struct { + *Alias + Type *SummaryTableColumnType `json:"type,omitempty"` // pointer → omits if nil + }{ + Alias: (*Alias)(&t), + } + if t.Type != "" { + aux.Type = &t.Type + } + return json.Marshal(aux) } type TableDetailsConfig struct { @@ -46,7 +95,7 @@ type GuppyConfig struct { FieldMapping []GuppyFieldMapping `json:"fieldMapping,omitempty"` AccessibleFieldCheckList []string `json:"accessibleFieldCheckList,omitempty"` AccessibleValidationField string `json:"accessibleValidationField,omitempty"` - ManifestMapping ManifestMapping `json:"manifestMapping,omitempty"` + ManifestMapping ManifestMapping `json:"manifestMapping"` } type GuppyFieldMapping struct { @@ -74,7 +123,7 @@ type ButtonConfig struct { LeftIcon string `json:"leftIcon,omitempty"` RightIcon string `json:"rightIcon,omitempty"` FileName string `json:"fileName,omitempty"` - ActionArgs ButtonActionArgs `json:"actionArgs,omitempty"` + ActionArgs ButtonActionArgs `json:"actionArgs"` } type ButtonActionArgs struct { @@ -99,7 +148,7 @@ type ConfigItem struct { } type Config struct { - SharedFilters SharedFiltersConfig `json:"sharedFilters,omitempty"` + SharedFilters SharedFiltersConfig `json:"sharedFilters"` ExplorerConfig []ConfigItem `json:"explorerConfig"` } diff --git a/gecko/config/fileSummaryConfig.go b/gecko/config/fileSummaryConfig.go new file mode 100644 index 0000000..ea146fe --- /dev/null +++ b/gecko/config/fileSummaryConfig.go @@ -0,0 +1,10 @@ +package config + +type FilesummaryConfig struct { + Config map[string]TableColumnsConfig `json:"config"` + BarChartColor string `json:"barChartColor"` + DefaultProject string `json:"defaultProject"` + BinslicePoints []int `json:"binslicePoints"` + IdField string `json:"idField"` + Index string `json:"index"` +} diff --git a/gecko/config/fileSummaryConfig_test.go b/gecko/config/fileSummaryConfig_test.go new file mode 100644 index 0000000..bbc70f6 --- /dev/null +++ b/gecko/config/fileSummaryConfig_test.go @@ -0,0 +1,200 @@ +package config + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +const filesummaryJSON = `{ + "config": { + "document_reference_title": { + "title": "Title", + "field": "document_reference_title" + }, + "document_reference_size": { + "cellRenderFunction": "HumanReadableString", + "type": "string", + "title": "File Size", + "field": "document_reference_size" + }, + "document_reference_source_path": { + "title": "Source Path", + "field": "document_reference_source_path" + } + }, + "binslicePoints": [ + 0, 1048576, 524288000, 1073741824, 107374182400, 9007199254740991 + ], + "barChartColor": "#e9724d", + "defaultProject": "gdc-esca", + "idField": "document_reference_id", + "index": "document_reference" +}` + +// --------------------------------------------------------------------- +// Round-trip Test (Order & Whitespace Insensitive) +// --------------------------------------------------------------------- + +func TestFilesummaryConfig_RoundTrip(t *testing.T) { + var cfg FilesummaryConfig + if err := json.Unmarshal([]byte(filesummaryJSON), &cfg); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + // Use Marshal, not MarshalIndent, for tighter JSON and easier comparison + marshaled, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + // Unmarshal both the original and the marshaled JSON into generic maps + // to enable a deep comparison that ignores field order (which reflect.DeepEqual + // does correctly for map[string]any but is sensitive to whitespace/indentation + // if comparing raw strings). + + var originalMap any + if err := json.Unmarshal([]byte(filesummaryJSON), &originalMap); err != nil { + t.Fatalf("Unmarshal original map failed: %v", err) + } + + var marshaledMap any + if err := json.Unmarshal(marshaled, &marshaledMap); err != nil { + t.Fatalf("Unmarshal marshaled map failed: %v", err) + } + + // The DeepEqual check on maps is the "order-insensitive" check + if !reflect.DeepEqual(originalMap, marshaledMap) { + // Use cmp.Diff on the unmarshaled structs for better debug output + // if the map comparison fails (optional, as the string output is often enough) + t.Errorf("Round-trip mismatch (Content differs - likely an omitted field or type issue)") + + // Marshal the original struct back out with indentation for a clearer visual comparison + wantJSON, _ := json.MarshalIndent(originalMap, "", " ") + gotJSON, _ := json.MarshalIndent(marshaledMap, "", " ") + + t.Errorf("--- want (Normalized) ---\n%s\n--- got (Normalized) ---\n%s\n", string(wantJSON), string(gotJSON)) + } else { + t.Log("Round-trip successful (order-insensitive, content preserved)") + } +} + +// --------------------------------------------------------------------- +// Field Validation Test +// --------------------------------------------------------------------- + +func TestFilesummaryConfig_Fields(t *testing.T) { + var cfg FilesummaryConfig + if err := json.Unmarshal([]byte(filesummaryJSON), &cfg); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + // --- Top-level Fields --- + if cfg.BarChartColor != "#e9724d" { + t.Errorf("BarChartColor = %q, want %q", cfg.BarChartColor, "#e9724d") + } + if cfg.DefaultProject != "gdc-esca" { + t.Errorf("DefaultProject = %q, want %q", cfg.DefaultProject, "gdc-esca") + } + if cfg.IdField != "document_reference_id" { + t.Errorf("IdField = %q, want %q", cfg.IdField, "document_reference_id") + } + if cfg.Index != "document_reference" { + t.Errorf("Index = %q, want %q", cfg.Index, "document_reference") + } + + // --- BinslicePoints Array --- + expectedBins := []int{0, 1048576, 524288000, 1073741824, 107374182400, 9007199254740991} + if !reflect.DeepEqual(cfg.BinslicePoints, expectedBins) { + t.Errorf("BinslicePoints mismatch: \nGot: %+v\nWant: %+v", cfg.BinslicePoints, expectedBins) + } + + // --- Config Map --- + if len(cfg.Config) != 3 { + t.Fatalf("expected 3 config entries, got %d", len(cfg.Config)) + } + + // Title column check + titleCol, ok := cfg.Config["document_reference_title"] + if !ok { + t.Fatal("missing document_reference_title") + } + wantTitleCol := TableColumnsConfig{ + Title: "Title", + Field: "document_reference_title", + // CellRenderFunction and Type should be zero values + } + if !reflect.DeepEqual(titleCol, wantTitleCol) { + t.Errorf("Title column mismatch:\nGot: %+v\nWant: %+v", titleCol, wantTitleCol) + } + + // Size column check + sizeCol, ok := cfg.Config["document_reference_size"] + if !ok { + t.Fatal("missing document_reference_size") + } + wantSizeCol := TableColumnsConfig{ + CellRenderFunction: "HumanReadableString", + Type: SummaryTableColumnTypeString, // Assumes this constant is 'string' + Title: "File Size", + Field: "document_reference_size", + } + if !reflect.DeepEqual(sizeCol, wantSizeCol) { + t.Errorf("Size column mismatch:\nGot: %+v\nWant: %+v", sizeCol, wantSizeCol) + } + + // Path column check + pathCol, ok := cfg.Config["document_reference_source_path"] + if !ok { + t.Fatal("missing document_reference_source_path") + } + wantPathCol := TableColumnsConfig{ + Title: "Source Path", + Field: "document_reference_source_path", + } + if !reflect.DeepEqual(pathCol, wantPathCol) { + t.Errorf("Path column mismatch:\nGot: %+v\nWant: %+v", pathCol, wantPathCol) + } +} + +// --------------------------------------------------------------------- +// Test omitempty behavior on Type (Example from original, adapted) +// --------------------------------------------------------------------- + +func TestFilesummaryConfig_TypeOmitEmpty(t *testing.T) { + input := `{ + "config": { + "test": { + "field": "test", + "title": "Test" + } + }, + "barChartColor": "#fff", + "defaultProject": "test", + "idField": "id", + "index": "test", + "binslicePoints": [1,2,3] + }` + + var cfg FilesummaryConfig + if err := json.Unmarshal([]byte(input), &cfg); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + col := cfg.Config["test"] + // Verify that the zero value is correctly unmarshaled (empty string) + if col.Type != "" { + t.Errorf("expected Type to be zero value, got %q", col.Type) + } + + out, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + + // Test that the JSON tag 'omitempty' is working for the Type field + if strings.Contains(string(out), `"type":`) { + t.Error("empty type should be omitted in JSON but was found") + } +} diff --git a/gecko/config/footerConfig.go b/gecko/config/footerConfig.go new file mode 100644 index 0000000..dc084fa --- /dev/null +++ b/gecko/config/footerConfig.go @@ -0,0 +1,210 @@ +package config + +import ( + "encoding/json" + "fmt" +) + +// --------------------------------------------------------------------- +// Styling +// --------------------------------------------------------------------- +type StylingMergeMode string + +const ( + MergeModeReplace StylingMergeMode = "replace" + MergeModeMerge StylingMergeMode = "merge" +) + +type StylingOverrideWithMergeControl struct { + Root string `json:"root"` + Layout string `json:"layout,omitempty"` + MergeMode StylingMergeMode `json:"mergeMode,omitempty"` + NavigationPanel string `json:"navigationPanel,omitempty"` + Button string `json:"button,omitempty"` + Label string `json:"label,omitempty"` + Item string `json:"item,omitempty"` +} + +// --------------------------------------------------------------------- +// Core Types +// --------------------------------------------------------------------- +type LinkType string + +const ( + LinkTypeGen3FF LinkType = "gen3ff" + LinkTypePortal LinkType = "portal" +) + +type BottomLinks struct { + Text string `json:"text"` + Href string `json:"href"` +} + +type ColumnLinks struct { + Heading string `json:"heading"` + Items []struct { + Text string `json:"text"` + Href string `json:"href,omitempty"` + LinkType LinkType `json:"linkType,omitempty"` + } `json:"items"` +} + +type FooterText struct { + Text string `json:"text"` + ClassName string `json:"className,omitempty"` +} + +type FooterLink struct { + FooterText + Href string `json:"href"` + LinkType LinkType `json:"linkType,omitempty"` +} + +type FooterLinks struct { + Links []FooterLink `json:"links"` + ClassName string `json:"className,omitempty"` +} + +type FooterLogo struct { + LogoLight string `json:"logolight"` + Logo string `json:"logo"` + Description string `json:"description"` + Width int `json:"width"` + Height int `json:"height"` + ClassName string `json:"className,omitempty"` + Href string `json:"href,omitempty"` +} + +// --------------------------------------------------------------------- +// FooterRow – Now a struct with custom JSON (un)marshaling +// --------------------------------------------------------------------- + +type FooterRow struct { + Kind string + Icon *FooterLogo + Text *FooterText + Link *FooterLink + Links *FooterLinks + Section *FooterSectionProps +} + +func (r *FooterRow) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + if len(raw) != 1 { + return fmt.Errorf("FooterRow must have exactly one key, got %d", len(raw)) + } + + for key, value := range raw { + r.Kind = key + switch key { + case "Icon": + var logo FooterLogo + if err := json.Unmarshal(value, &logo); err != nil { + return err + } + r.Icon = &logo + + case "Text": + var text FooterText + if err := json.Unmarshal(value, &text); err != nil { + return err + } + r.Text = &text + + case "Link": + var link FooterLink + if err := json.Unmarshal(value, &link); err != nil { + return err + } + r.Link = &link + + case "Links": + var links FooterLinks + if err := json.Unmarshal(value, &links); err != nil { + return err + } + r.Links = &links + + case "Section": + var section FooterSectionProps + if err := json.Unmarshal(value, §ion); err != nil { + return err + } + r.Section = §ion + + default: + return fmt.Errorf("unknown FooterRow key: %s", key) + } + } + return nil +} + +// MarshalJSON for round-trip +func (r FooterRow) MarshalJSON() ([]byte, error) { + var obj map[string]any + switch r.Kind { + case "Icon": + if r.Icon == nil { + return nil, fmt.Errorf("Logo is nil") + } + obj = map[string]any{"Icon": r.Icon} + case "Text": + if r.Text == nil { + return nil, fmt.Errorf("Text is nil") + } + obj = map[string]any{"Text": r.Text} + case "Link": + if r.Link == nil { + return nil, fmt.Errorf("Link is nil") + } + obj = map[string]any{"Link": r.Link} + case "Links": + if r.Links == nil { + return nil, fmt.Errorf("Links is nil") + } + obj = map[string]any{"Links": r.Links} + case "Section": + if r.Section == nil { + return nil, fmt.Errorf("Section is nil") + } + obj = map[string]any{"Section": r.Section} + default: + return nil, fmt.Errorf("unknown FooterRow kind: %s", r.Kind) + } + return json.Marshal(obj) +} + +// --------------------------------------------------------------------- +// Column / Section +// --------------------------------------------------------------------- +type FooterColumnProps struct { + Heading string `json:"heading,omitempty"` + Rows []FooterRow `json:"rows"` + ClassNames *StylingOverrideWithMergeControl `json:"classNames,omitempty"` + BasePage bool `json:"basePage,omitempty"` +} + +type FooterSectionProps struct { + Columns []FooterColumnProps `json:"columns"` + ClassName string `json:"className,omitempty"` + BasePage bool `json:"basePage,omitempty"` +} + +// --------------------------------------------------------------------- +// FooterProps +// --------------------------------------------------------------------- +type FooterProps struct { + BottomLinks []BottomLinks `json:"bottomLinks,omitempty"` + ColumnLinks []ColumnLinks `json:"columnLinks,omitempty"` + FooterLogos []FooterLogo `json:"footerLogos,omitempty"` + FooterRightLogos []FooterLogo `json:"footerRightLogos,omitempty"` + RightSection *FooterSectionProps `json:"rightSection,omitempty"` + LeftSection *FooterSectionProps `json:"leftSection,omitempty"` + ClassNames *StylingOverrideWithMergeControl `json:"classNames,omitempty"` + CustomFooter json.RawMessage `json:"customFooter,omitempty"` + BasePage bool `json:"basePage,omitempty"` +} diff --git a/gecko/config/navConfig.go b/gecko/config/navConfig.go new file mode 100644 index 0000000..542352a --- /dev/null +++ b/gecko/config/navConfig.go @@ -0,0 +1,110 @@ +package config + +import ( + "encoding/json" +) + +// --------------------------------------------------------------------- +// NavigationButtonProps +// --------------------------------------------------------------------- +type NavigationButtonProps struct { + Icon string `json:"icon"` + Tooltip string `json:"tooltip"` + Href string `json:"href"` + NoBasePath *bool `json:"noBasePath,omitempty"` + Name string `json:"name"` + IconHeight string `json:"iconHeight,omitempty"` + Title string `json:"title,omitempty"` // present in the example + ClassNames *StylingOverrideWithMergeControl `json:"classNames,omitempty"` +} + +// --------------------------------------------------------------------- +// NavigationBarLogo +// --------------------------------------------------------------------- +type NavigationBarLogo struct { + Src string `json:"src"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Width float64 `json:"width,omitempty"` // JSON numbers → float64 + Height float64 `json:"height,omitempty"` + NoBasePath *bool `json:"noBasePath,omitempty"` + Divider *bool `json:"divider,omitempty"` + BasePath string `json:"basePath,omitempty"` + Href string `json:"href"` + OnToggle json.RawMessage `json:"onToggle,omitempty"` // function → ignored on server side + Basepage *bool `json:"basepage,omitempty"` + ClassNames *StylingOverrideWithMergeControl `json:"classNames,omitempty"` +} + +// --------------------------------------------------------------------- +// NavigationProps +// --------------------------------------------------------------------- +type NavigationProps struct { + Logo *NavigationBarLogo `json:"logo,omitempty"` + Items []NavigationButtonProps `json:"items"` + Title string `json:"title,omitempty"` + LoginIcon json.RawMessage `json:"loginIcon,omitempty"` // ReactElement | string + ClassNames *StylingOverrideWithMergeControl `json:"classNames"` +} + +// --------------------------------------------------------------------- +// LeftNavBarProps +// --------------------------------------------------------------------- +type LeftNavBarProps struct { + Title string `json:"title"` + Description string `json:"description"` + Icon string `json:"icon"` + Href string `json:"href"` + Perms any `json:"perms"` // can be string | null +} + +// --------------------------------------------------------------------- +// TopBarItem (inside TopBarProps) +// --------------------------------------------------------------------- +type TopBarItem struct { + ClassNames struct { + Button string `json:"button"` + Label string `json:"label"` + Root string `json:"root"` + } `json:"classNames,omitempty"` + Href string `json:"href,omitempty"` + Name string `json:"name,omitempty"` +} + +// --------------------------------------------------------------------- +// TopBarProps +// --------------------------------------------------------------------- +type TopBarProps struct { + Items []TopBarItem `json:"items,omitempty"` + LoginButtonVisibility string `json:"loginButtonVisibility,omitempty"` // "hidden" | … +} + +// --------------------------------------------------------------------- +// HeaderProps (only the fields that appear in the config) +// --------------------------------------------------------------------- +type HeaderProps struct { + Top TopBarProps `json:"topBar"` // name in JSON is "topBar" + Navigation NavigationProps `json:"navigation"` + LeftNav []LeftNavBarProps `json:"leftnav"` + BasePage bool `json:"basePage,omitempty"` + // siteProps, banners, type, children … are omitted because they are not in the example +} + +// --------------------------------------------------------------------- +// NavPageLayoutProps – the full layout object +// --------------------------------------------------------------------- +type NavPageLayoutProps struct { + HeaderProps HeaderProps `json:"headerProps"` // we only have the part that is in the example + FooterProps FooterProps `json:"footerProps"` // re-use existing FooterProps + HeaderMetadata HeaderMetadata `json:"headerMetadata"` + // MainProps, CustomHeaderComponent, CustomFooterComponent omitted (not in example) +} + +// --------------------------------------------------------------------- +// HeaderMetadata +// --------------------------------------------------------------------- +type HeaderMetadata struct { + Title string `json:"title"` + Content string `json:"content"` + Key string `json:"key"` +} diff --git a/gecko/config/navConfig_test.go b/gecko/config/navConfig_test.go new file mode 100644 index 0000000..daaa619 --- /dev/null +++ b/gecko/config/navConfig_test.go @@ -0,0 +1,252 @@ +package config + +import ( + "encoding/json" + "reflect" + "testing" +) + +const exampleConfig = `{ + "headerProps": { + "topBar": { + "items": [ + { + "href": "https://www.ohsu.edu/knight-cancer-institute", + "name": "CBDS", + "classNames": { + "root": "", + "label": "", + "button": "" + } + } + ], + "loginButtonVisibility": "hidden" + }, + "navigation": { + "classNames": { + "root": "bg-base-max text-primary opacity-100 hover:opacity-100", + "item": "py-2 px-4 hover:bg-base-lightest hover:text-base-contrast", + "navigationPanel": "bg-base-max text-primary" + }, + "logo": { + "src": "/icons/ohsu.svg", + "width": 52.5, + "height": 40, + "href": "/Apps", + "title": "CALYPR" + }, + "items": [ + { + "icon": "gen3:exploration", + "href": "/Explorer", + "name": "Exploration", + "tooltip": "The Exploration Page enables discovery of the data at the subject level and features a cohort builder.", + "title": "Explorer" + }, + { + "icon": "gen3:profile", + "href": "/Profile", + "name": "Profile", + "tooltip": "Create API keys for programmatic data access, and review your authorization privileges to datasets and services.", + "title": "Profile" + } + ] + }, + "leftnav": [ + { + "title": "Home", + "description": "Home Apps page", + "icon": "/icons/home.svg", + "href": "/Apps", + "perms": null + }, + { + "title": "Directory Structure", + "description": "Search for files via a tree based interactive search", + "icon": "/icons/binary-tree.svg", + "href": "/Miller", + "perms": null + }, + { + "title": "File Summary", + "description": "Overview of file system usage", + "icon": "/icons/file.svg", + "href": "/Filesummary", + "perms": null + }, + { + "title": "Project Discovery", + "description": "Explore project summaries of every project in CALYPR", + "icon": "/icons/compass.svg", + "href": "/Discovery", + "perms": null + }, + { + "title": "Image Viewer", + "description": "View available .ome.tif images using Avivator", + "icon": "/icons/layers-intersect.svg", + "href": "/AvailableImages", + "perms": null + }, + { + "title": "My Projects", + "description": "Identify the list of projects in which you have access", + "icon": "/icons/key.svg", + "href": "/MyProjects", + "perms": null + } + ] + }, + "footerProps": { + "classNames": { + "root": "", + "layout": "flex items-center justify-center" + }, + "rightSection": { + "columns": [ + { + "rows": [ + { + "Icon": { + "logo": "/icons/knight.svg", + "logolight": "/icons/knight_white.svg", + "width": 100, + "height": 47, + "description": "Knight Cancer Institute" + } + } + ] + } + ] + } + }, + "headerMetadata": { + "title": "CALYPR", + "content": "Cancer Analytics Platform", + "key": "calypr-main" + } +}` + +// --------------------------------------------------------------------- +// Round-trip Test (Order & Whitespace Insensitive) +// --------------------------------------------------------------------- + +func TestNavPageLayout_RoundTrip(t *testing.T) { + var cfg NavPageLayoutProps + if err := json.Unmarshal([]byte(exampleConfig), &cfg); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + // Marshal the struct back into JSON (without indentation) + marshaled, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + // Unmarshal both the original and the marshaled JSON into generic maps + // to perform an order-insensitive DeepEqual comparison. + var originalMap any + if err := json.Unmarshal([]byte(exampleConfig), &originalMap); err != nil { + t.Fatalf("Unmarshal original map failed: %v", err) + } + + var marshaledMap any + if err := json.Unmarshal(marshaled, &marshaledMap); err != nil { + t.Fatalf("Unmarshal marshaled map failed: %v", err) + } + + // Use DeepEqual on maps to ignore key ordering and whitespace/indentation differences + if !reflect.DeepEqual(originalMap, marshaledMap) { + t.Errorf("Round-trip mismatch (Content differs - likely an omitted field or type issue)") + + // Remarshal with indentation for clear visual debugging output + wantJSON, _ := json.MarshalIndent(originalMap, "", " ") + gotJSON, _ := json.MarshalIndent(marshaledMap, "", " ") + + t.Errorf("--- want (Normalized) ---\n%s\n--- got (Normalized) ---\n%s\n", string(wantJSON), string(gotJSON)) + } else { + t.Log("Round-trip successful (order-insensitive, content preserved)") + } +} + +// --------------------------------------------------------------------- +// Field Validation Test +// --------------------------------------------------------------------- + +func TestNavPageLayout_Fields(t *testing.T) { + var cfg NavPageLayoutProps + if err := json.Unmarshal([]byte(exampleConfig), &cfg); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + // --- headerMetadata check --- + if cfg.HeaderMetadata.Title != "CALYPR" { + t.Errorf("headerMetadata title = %q, want %q", cfg.HeaderMetadata.Title, "CALYPR") + } + if cfg.HeaderMetadata.Content != "Cancer Analytics Platform" { + t.Errorf("headerMetadata content = %q, want %q", cfg.HeaderMetadata.Content, "Cancer Analytics Platform") + } + + // --- topBar check --- + if cfg.HeaderProps.Top.LoginButtonVisibility != "hidden" { + t.Errorf("loginButtonVisibility = %q, want %q", cfg.HeaderProps.Top.LoginButtonVisibility, "hidden") + } + if len(cfg.HeaderProps.Top.Items) != 1 { + t.Fatalf("expected 1 topBar item, got %d", len(cfg.HeaderProps.Top.Items)) + } + if cfg.HeaderProps.Top.Items[0].Name != "CBDS" { + t.Errorf("topBar item name = %q, want %q", cfg.HeaderProps.Top.Items[0].Name, "CBDS") + } + + // --- navigation logo check --- + logo := cfg.HeaderProps.Navigation.Logo + if logo == nil { + t.Fatal("navigation logo missing") + } + if logo.Src != "/icons/ohsu.svg" || logo.Title != "CALYPR" { + t.Errorf("navigation logo mismatch:\nGot: %+v\nWant: Src:/icons/ohsu.svg, Title:CALYPR", logo) + } + + // --- navigation items check --- + if len(cfg.HeaderProps.Navigation.Items) != 2 { + t.Fatalf("expected 2 navigation items, got %d", len(cfg.HeaderProps.Navigation.Items)) + } + if cfg.HeaderProps.Navigation.Items[0].Name != "Exploration" { + t.Errorf("first navigation name = %q, want %q", cfg.HeaderProps.Navigation.Items[0].Name, "Exploration") + } + if cfg.HeaderProps.Navigation.Items[1].Tooltip != "Create API keys for programmatic data access, and review your authorization privileges to datasets and services." { + t.Errorf("second navigation tooltip mismatch") + } + + // --- leftnav check --- + if len(cfg.HeaderProps.LeftNav) != 6 { + t.Fatalf("expected 6 leftnav items, got %d", len(cfg.HeaderProps.LeftNav)) + } + if cfg.HeaderProps.LeftNav[2].Title != "File Summary" { + t.Errorf("third leftnav title = %q, want %q", cfg.HeaderProps.LeftNav[2].Title, "File Summary") + } + // Check a null value for perms + if cfg.HeaderProps.LeftNav[2].Perms != nil { + t.Errorf("leftnav item perms expected to be nil, got %v", cfg.HeaderProps.LeftNav[2].Perms) + } + + // --- footer section check (re-using your original logic structure) --- + if cfg.FooterProps.RightSection == nil || len(cfg.FooterProps.RightSection.Columns) == 0 { + t.Fatal("footer rightSection or columns missing") + } + + rows := cfg.FooterProps.RightSection.Columns[0].Rows + if len(rows) != 1 { + t.Fatalf("expected 1 footer row, got %d", len(rows)) + } + iconRow := rows[0].Icon + if iconRow == nil { + t.Fatal("footer Icon row missing") + } + if iconRow.Description != "Knight Cancer Institute" { + t.Errorf("footer description = %q, want %q", iconRow.Description, "Knight Cancer Institute") + } + if iconRow.Width != 100 { + t.Errorf("footer icon width = %d, want %d", iconRow.Width, 100) + } +} diff --git a/gecko/handleConfig.go b/gecko/handleConfig.go index 01281e7..c8779e9 100644 --- a/gecko/handleConfig.go +++ b/gecko/handleConfig.go @@ -1,7 +1,9 @@ package gecko import ( + "database/sql" "encoding/json" + "errors" "fmt" "net/http" @@ -10,26 +12,27 @@ import ( ) // handleConfigListGET godoc -// @Summary List all configurations -// @Description Retrieve a list of all available configurations +// @Summary List all configuration IDs for a specific type +// @Description Retrieve a list of all available configuration IDs for the given type (table). // @Tags Config // @Accept json // @Produce json -// @Success 200 {array} config.Config -// @Failure 404 {object} ErrorResponse "No configs found" +// @Param configType path string true "Configuration Type (table name)" +// @Success 200 {array} string "List of config IDs" +// @Failure 404 {object} ErrorResponse "No configs found for this type" // @Failure 500 {object} ErrorResponse "Server error" // @Router /config/list [get] func (server *Server) handleConfigListGET(ctx iris.Context) { - configList, err := configList(server.db) + configList, err := configListByType(server.db, "explorer") if configList == nil && err == nil { - errResponse := newErrorResponse("No configs found", 404, nil) - errResponse.log.write(server.logger) + errResponse := newErrorResponse(fmt.Sprintf("No configs found for type: %s", "explorer"), 404, nil) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } if err != nil { - errResponse := newErrorResponse(fmt.Sprintf("%s", err), 500, nil) - errResponse.log.write(server.logger) + errResponse := newErrorResponse(fmt.Sprintf("Database error: %s", err), 500, nil) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } @@ -38,114 +41,182 @@ func (server *Server) handleConfigListGET(ctx iris.Context) { // handleConfigGET godoc // @Summary Get a specific configuration -// @Description Retrieve configuration by ID +// @Description Retrieve configuration by configType and configId // @Tags Config // @Produce json +// @Param configType path string true "Configuration Type (table name)" // @Param configId path string true "Configuration ID" // @Success 200 {object} config.Config "Configuration details" // @Failure 404 {object} ErrorResponse "Config not found" // @Failure 500 {object} ErrorResponse "Server error" -// @Router /config/{configId} [get] +// @Router /config/{configType}/{configId} [get] func (server *Server) handleConfigGET(ctx iris.Context) { + configType := ctx.Params().Get("configType") configId := ctx.Params().Get("configId") - doc, err := configGET(server.db, configId) - if doc == nil && err == nil { - msg := fmt.Sprintf("no configId found with configId: %s", configId) + + if configType == "" { + configType = "explorer" + } + + var cfg config.Configurable // Use the interface type + + switch configType { + case "explorer": + cfg = &config.Config{} + case "nav": + cfg = &config.NavPageLayoutProps{} + case "file_summary": + cfg = &config.FilesummaryConfig{} + case "apps_page": + cfg = &config.AppsConfig{} + default: + msg := fmt.Sprintf("Unknown config type: %s", configType) + errResponse := newErrorResponse(msg, 400, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + return + } + + // Pass configType to the generic GET function + err := configGETGeneric(server.db, configId, configType, cfg) + // returning 404 on an empty config might be a bit controversial, + // but I think it will stock alot of edge cases + if cfg.IsZero() && err == nil || errors.Is(err, sql.ErrNoRows) { + msg := fmt.Sprintf("no config found with configId: %s of type: %s", configId, configType) errResponse := newErrorResponse(msg, 404, nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } + if err != nil { msg := fmt.Sprintf("config query failed: %s", err.Error()) errResponse := newErrorResponse(msg, 500, nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } - jsonResponseFrom(doc, http.StatusOK).write(ctx) + + // Send back the populated config struct + jsonResponseFrom(cfg, http.StatusOK).write(ctx) } // handleConfigDELETE godoc // @Summary Delete a configuration -// @Description Delete configuration by ID +// @Description Delete configuration by configType and configId // @Tags Config // @Produce json +// @Param configType path string true "Configuration Type (table name)" // @Param configId path string true "Configuration ID" example:"config_123" // @Success 200 {object} map[string]interface{} "Configuration deleted" // @Failure 404 {object} ErrorResponse "Config not found" // @Failure 500 {object} ErrorResponse "Server error" -// @Router /config/{configId} [delete] +// @Router /config/{configType}/{configId} [delete] func (server *Server) handleConfigDELETE(ctx iris.Context) { + configType := ctx.Params().Get("configType") configId := ctx.Params().Get("configId") - doc, err := configDELETE(server.db, configId) - if doc == false && err == nil { - msg := fmt.Sprintf("no configId found with configId: %s", configId) + + // Pass configType to the generic DELETE function + deleted, err := configDELETEGeneric(server.db, configId, configType) + if deleted == false && err == nil { + msg := fmt.Sprintf("no configId found with configId: %s in type: %s", configId, configType) errResponse := newErrorResponse(msg, 404, nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } if err != nil { msg := fmt.Sprintf("config query failed: %s", err.Error()) errResponse := newErrorResponse(msg, 500, nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } - okmsg := map[string]any{"code": 200, "message": fmt.Sprintf("DELETED: %s", configId)} - jsonResponseFrom(okmsg, http.StatusOK).write(ctx) + jsonResponseFrom( + map[string]any{ + "code": 200, + "message": fmt.Sprintf("DELETED: %s from type: %s", configId, configType), + }, + http.StatusOK, + ).write(ctx) } // handleConfigPUT updates a configuration by ID. // @Summary Update configuration -// @Description Replaces or updates the configuration items for a given config ID +// @Description Replaces or updates the configuration items for a given config ID in a specific type (table) // @Tags Config -// @Accept json -// @Produce json +// @Accept json +// @Produce json +// @Param configType path string true "Configuration Type (table name)" // @Param configId path string true "Configuration ID" // @Param body body config.Config true "Configuration items to set" // @Success 200 {object} jsonResponse "Configuration successfully updated" // @Failure 400 {object} ErrorResponse "Invalid request body" -// @Failure 404 {object} ErrorResponse "Config not found" // @Failure 500 {object} ErrorResponse "Internal server error" -// @Router /config/{configId} [put] +// @Router /config/{configType}/{configId} [put] func (server *Server) handleConfigPUT(ctx iris.Context) { configId := ctx.Params().Get("configId") - data := config.Config{} + configType := ctx.Params().Get("configType") + + var cfg config.Configurable // Use the interface type + + switch configType { + case "explorer": + cfg = &config.Config{} + case "nav": + cfg = &config.NavPageLayoutProps{} + case "file_summary": + cfg = &config.FilesummaryConfig{} + case "apps_page": + cfg = &config.AppsConfig{} + default: + msg := fmt.Sprintf("Unknown config type: %s", configType) + errResponse := newErrorResponse(msg, 400, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + return + } + body, err := ctx.GetBody() if err != nil { msg := fmt.Sprintf("GetBody() failed: %s", err.Error()) errResponse := newErrorResponse(msg, 500, nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } if !json.Valid(body) { msg := "Invalid JSON format" errResponse := newErrorResponse(msg, 400, nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } - errResponse := unmarshal(body, &data) + // Note: We unmarshal into the specific struct pointer held by cfg + errResponse := unmarshal(body, cfg) if errResponse != nil { msg := fmt.Sprintf("body data unmarshal failed: %s", errResponse.err) errResponse := newErrorResponse(msg, 400, nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } - err = configPUT(server.db, configId, data) + // Pass configType to the generic PUT function + err = configPUTGeneric(server.db, configId, configType, cfg) if err != nil { msg := fmt.Sprintf("configPut failed: %s", err.Error()) errResponse := newErrorResponse(msg, 500, nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } - okmsg := map[string]any{"code": 200, "message": fmt.Sprintf("ACCEPTED: %s", configId)} - jsonResponseFrom(okmsg, http.StatusOK).write(ctx) + jsonResponseFrom( + map[string]any{ + "code": 200, + "message": fmt.Sprintf("ACCEPTED: %s for type: %s", configId, configType), + }, + http.StatusOK, + ).write(ctx) } diff --git a/gecko/handleDir.go b/gecko/handleDir.go new file mode 100644 index 0000000..92be113 --- /dev/null +++ b/gecko/handleDir.go @@ -0,0 +1,143 @@ +package gecko + +import ( + "fmt" + "net/http" + "path" + "slices" + "strings" + + "github.com/bmeg/grip-graphql/middleware" + "github.com/bmeg/grip/gripql" + "github.com/kataras/iris/v12" +) + +// handleListProjects godoc +// @Summary Retrieve directory information for a project +// @Description Retrieve directory details for the given project ID and Directory path +// @Tags Directory +// @Produce json +// @Param projectId path string true "Project ID (format: program-project)" +// @Success 200 {object} map[string]interface{} "Directory information" +// @Failure 400 {object} ErrorResponse "Invalid request body or Directory path" +// @Failure 500 {object} ErrorResponse "Server error" +// @Router /dir/{projectId} [get] +func (server *Server) handleListProjects(ctx iris.Context) { + projs, errResponse := server.GetProjectsFromToken(ctx, &middleware.ProdJWTHandler{}, "read", "*") + if errResponse != nil { + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + ctx.StopExecution() + return + } + server.Logger.Info("projects: %s", projs) + q := gripql.V().HasLabel("ResearchStudy").Has(gripql.Within("auth_resource_path", projs...)).As("f0").Render(map[string]any{"project": "$f0.auth_resource_path"}) + res, err := server.gripqlClient.Traversal( + ctx, + &gripql.GraphQuery{Graph: server.gripGraphName, Query: q.Statements}, + ) + if err != nil { + errResponse = newErrorResponse("internal server error", http.StatusInternalServerError, &err) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + ctx.StopExecution() + return + } + out := []string{} + for r := range res { + renda, ok := r.GetRender().GetStructValue().AsMap()["project"].(string) + if !ok { + continue + } + if !slices.Contains(out, renda) { + out = append(out, renda) + } + } + jsonResponseFrom(out, 200).write(ctx) +} + +type DirectoryResponse struct { + Directories []map[string]any `json:"Directories"` + Documents []map[string]any `json:"Documents"` + Message string `json:"Message"` + Code string `json:"Code"` +} + +// handleDirGet godoc +// @Summary Retrieve directory information for a project +// @Description Retrieve directory details for the given project ID and Directory path +// @Tags Directory +// @Produce json +// @Param projectId path string true "Project ID (format: program-project)" +// @Param directory_path query string true "Directory Path (e.g., /data/my-dir)" +// @Success 200 {object} map[string]interface{} "Directory information" +// @Failure 400 {object} ErrorResponse "Invalid request body or Directory path" +// @Failure 403 {object} ErrorResponse "User is not allowed on any resource path" +// @Failure 500 {object} ErrorResponse "Server error" +// @Router /dir/{projectId} [get] +func (server *Server) handleDirGet(ctx iris.Context) { + projectId := ctx.Params().Get("projectId") + dirPath := ctx.URLParam("directory") + + if dirPath == "" || !isValidPosixPath(&dirPath) { + errResponse := newErrorResponse(fmt.Sprintf("Invalid or missing Directory path: '%s'", dirPath), http.StatusBadRequest, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + return + } + + project_split := strings.Split(projectId, "-") + if len(project_split) != 2 { + errResponse := newErrorResponse(fmt.Sprintf("Failed to parse request body: %v", fmt.Sprintf("incorrect path %s", ctx.Request().URL)), http.StatusNotFound, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + ctx.StopExecution() + return + } + projectId = "/programs/" + project_split[0] + "/projects/" + project_split[1] + + // Shouldn't have to filter on base query because rootDir_Directory edge only ever connects to the root directory + q := gripql.V().HasLabel("ResearchStudy").Has(gripql.Eq("auth_resource_path", projectId)).OutE("rootDir_Directory").OutNull().OutNull() + if dirPath != "/" { + for splStr := range strings.SplitSeq(strings.Trim(dirPath, "/"), "/") { + q = q.Has(gripql.Eq("name", splStr)).OutNull() + } + } + + server.Logger.Info("Executing query: %s", q.String()) + + res, err := server.gripqlClient.Traversal(ctx, &gripql.GraphQuery{Graph: server.gripGraphName, Query: q.Statements}) + if err != nil { + errResponse := newErrorResponse("internal server error", http.StatusInternalServerError, &err) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + ctx.StopExecution() + return + } + out := []any{} + for r := range res { + out = append(out, r.GetVertex()) + } + + jsonResponseFrom(out, 200).write(ctx) +} + +func isValidPosixPath(p *string) bool { + if strings.ContainsRune(*p, '\000') { + return false + } + if !path.IsAbs(*p) { + return false + } + cleaned := path.Clean(*p) + if *p == "" || cleaned == "." { + return false + } + if cleaned == ".." || strings.HasPrefix(cleaned, "/..") { + return false + } + if strings.Contains(*p, "\\") { + return false + } + return true +} diff --git a/gecko/handleVector.go b/gecko/handleVector.go index ed6ec4c..55bf40a 100644 --- a/gecko/handleVector.go +++ b/gecko/handleVector.go @@ -23,7 +23,7 @@ func (server *Server) handleListCollections(ctx iris.Context) { if err != nil { msg := fmt.Sprintf("failed to list collections: %s", err.Error()) errResponse := newErrorResponse(msg, adapter.MapQdrantErrorToHTTPStatus(err), nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } @@ -55,7 +55,7 @@ func (server *Server) handleCreateCollection(ctx iris.Context) { if err := ctx.ReadJSON(&reqBody); err != nil { msg := fmt.Sprintf("invalid request body: JSON parsing failed: %s", err.Error()) errResponse := newErrorResponse(msg, http.StatusBadRequest, &err) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } @@ -66,7 +66,7 @@ func (server *Server) handleCreateCollection(ctx iris.Context) { if !ok { msg := fmt.Sprintf("invalid distance: %s", params.Distance) errResponse := newErrorResponse(msg, http.StatusBadRequest, nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } @@ -91,7 +91,7 @@ func (server *Server) handleCreateCollection(ctx iris.Context) { if err != nil { msg := fmt.Sprintf("failed to create collection: %s", err.Error()) errResponse := newErrorResponse(msg, adapter.MapQdrantErrorToHTTPStatus(err), nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } @@ -118,13 +118,13 @@ func (server *Server) handleGetCollection(ctx iris.Context) { if err != nil { msg := fmt.Sprintf("failed to get collection info: %s", err.Error()) errResponse := newErrorResponse(msg, adapter.MapQdrantErrorToHTTPStatus(err), nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } jsonResponse := jsonResponseFrom(resp, http.StatusOK) - server.logger.Info("%#v", jsonResponse) + server.Logger.Info("%#v", jsonResponse) jsonResponse.write(ctx) } @@ -147,7 +147,7 @@ func (server *Server) handleUpdateCollection(ctx iris.Context) { if err := ctx.ReadJSON(&req); err != nil { msg := fmt.Sprintf("invalid request body: JSON parsing failed: %s", err.Error()) errResponse := newErrorResponse(msg, http.StatusBadRequest, &err) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } @@ -156,7 +156,7 @@ func (server *Server) handleUpdateCollection(ctx iris.Context) { if err != nil { msg := fmt.Sprintf("failed to update collection: %s", err.Error()) errResponse := newErrorResponse(msg, adapter.MapQdrantErrorToHTTPStatus(err), nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } @@ -180,7 +180,7 @@ func (server *Server) handleDeleteCollection(ctx iris.Context) { if err != nil { msg := fmt.Sprintf("failed to delete collection: %s", err.Error()) errResponse := newErrorResponse(msg, adapter.MapQdrantErrorToHTTPStatus(err), nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } @@ -206,7 +206,7 @@ func (server *Server) handleGetPoint(ctx iris.Context) { if idStr == "" || collection == "" { err := fmt.Errorf("collection or id not provide") errResponse := newErrorResponse("collection or id is not provided", http.StatusBadRequest, &err) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } @@ -214,7 +214,7 @@ func (server *Server) handleGetPoint(ctx iris.Context) { _, err := uuid.Parse(idStr) if err != nil { errResponse := newErrorResponse("invalid UUID", http.StatusBadRequest, &err) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } @@ -229,13 +229,13 @@ func (server *Server) handleGetPoint(ctx iris.Context) { if err != nil { msg := fmt.Sprintf("failed to get point: %s", err.Error()) errResponse := newErrorResponse(msg, adapter.MapQdrantErrorToHTTPStatus(err), nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } if len(resp) == 0 { errResponse := newErrorResponse("point not found", http.StatusNotFound, nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } @@ -261,14 +261,14 @@ func (server *Server) handleQueryPoints(ctx iris.Context) { if err := ctx.ReadJSON(&req); err != nil { msg := fmt.Sprintf("invalid request body: JSON parsing failed: %s", err.Error()) errResponse := newErrorResponse(msg, http.StatusBadRequest, &err) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } qdrantReq, err := adapter.ToQdrantQuery(req, collection) if err != nil { errResponse := newErrorResponse(fmt.Sprintf("invalid query parameter: %s", err.Error()), http.StatusBadRequest, &err) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } @@ -276,7 +276,7 @@ func (server *Server) handleQueryPoints(ctx iris.Context) { if err != nil { msg := fmt.Sprintf("failed to query points: %s", err.Error()) errResponse := newErrorResponse(msg, adapter.MapQdrantErrorToHTTPStatus(err), nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } @@ -303,14 +303,14 @@ func (server *Server) handleUpsertPoints(ctx iris.Context) { if err := ctx.ReadJSON(&reqBody); err != nil { msg := fmt.Sprintf("invalid request body: JSON parsing failed: %s", err.Error()) errResponse := newErrorResponse(msg, http.StatusBadRequest, nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } upsertReq, err := adapter.ToQdrantUpsert(reqBody, collection) if err != nil { errResponse := newErrorResponse(err.Error(), http.StatusBadRequest, nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } @@ -318,7 +318,7 @@ func (server *Server) handleUpsertPoints(ctx iris.Context) { if err != nil { msg := fmt.Sprintf("failed to upsert points: %s", err.Error()) errResponse := newErrorResponse(msg, adapter.MapQdrantErrorToHTTPStatus(err), nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } @@ -343,7 +343,7 @@ func (server *Server) handleDeletePoints(ctx iris.Context) { if err := ctx.ReadJSON(&req); err != nil { msg := fmt.Sprintf("invalid request body: JSON parsing failed: %s", err.Error()) errResponse := newErrorResponse(msg, http.StatusBadRequest, &err) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } @@ -352,7 +352,7 @@ func (server *Server) handleDeletePoints(ctx iris.Context) { if err != nil { msg := fmt.Sprintf("failed to delete points: %s", err.Error()) errResponse := newErrorResponse(msg, adapter.MapQdrantErrorToHTTPStatus(err), nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } @@ -362,7 +362,7 @@ func (server *Server) handleDeletePoints(ctx iris.Context) { if err != nil { msg := fmt.Sprintf("failed to delete points: %s", err.Error()) errResponse := newErrorResponse(msg, adapter.MapQdrantErrorToHTTPStatus(err), nil) - errResponse.log.write(server.logger) + errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } diff --git a/gecko/logging.go b/gecko/logging.go index 92a7e82..8a1881b 100644 --- a/gecko/logging.go +++ b/gecko/logging.go @@ -26,31 +26,24 @@ type Log struct { } func (cache *LogCache) write(logger arborist.Logger) { - for _, log := range cache.logs { - logger.Print(log.msg) + if l, ok := logger.(*LogHandler); ok { + for _, log := range cache.logs { + switch log.lvl { + case LogLevelDebug: + l.Debug("%s", log.msg) + case LogLevelInfo: + l.Info("%s", log.msg) + case LogLevelWarning: + l.Warning("%s", log.msg) + case LogLevelError: + l.Error("%s", log.msg) + default: + l.Print("%s", log.msg) + } + } } } -func (handler *LogHandler) Print(format string, a ...any) { - handler.logger.Print(sprintf(format, a...)) -} - -func (handler *LogHandler) Debug(format string, a ...any) { - handler.logger.Print(logMsg(LogLevelDebug, format, a...)) -} - -func (handler *LogHandler) Info(format string, a ...any) { - handler.logger.Print(logMsg(LogLevelInfo, format, a...)) -} - -func (handler *LogHandler) Warning(format string, a ...any) { - handler.logger.Print(logMsg(LogLevelWarning, format, a...)) -} - -func (handler *LogHandler) Error(format string, a ...any) { - handler.logger.Print(logMsg(LogLevelError, format, a...)) -} - func (cache *LogCache) Debug(format string, a ...any) { log := Log{ lvl: LogLevelDebug, @@ -83,6 +76,26 @@ func (cache *LogCache) Error(format string, a ...any) { cache.logs = append(cache.logs, log) } +func (handler *LogHandler) Print(format string, a ...any) { + handler.Logger.Print(sprintf(format, a...)) +} + +func (handler *LogHandler) Debug(format string, a ...any) { + handler.Logger.Print(logMsg(LogLevelDebug, format, a...)) +} + +func (handler *LogHandler) Info(format string, a ...any) { + handler.Logger.Print(logMsg(LogLevelInfo, format, a...)) +} + +func (handler *LogHandler) Warning(format string, a ...any) { + handler.Logger.Print(logMsg(LogLevelWarning, format, a...)) +} + +func (handler *LogHandler) Error(format string, a ...any) { + handler.Logger.Print(logMsg(LogLevelError, format, a...)) +} + func logMsg(lvl arborist.LogLevel, format string, a ...any) string { msg := sprintf(format, a...) msg = fmt.Sprintf("%s: %s", lvl, msg) diff --git a/gecko/middleware.go b/gecko/middleware.go index 54b0591..0f3dc06 100644 --- a/gecko/middleware.go +++ b/gecko/middleware.go @@ -1,8 +1,13 @@ package gecko import ( + "fmt" + "net/http" + "slices" + "strings" "time" + "github.com/bmeg/grip-graphql/middleware" "github.com/kataras/iris/v12" ) @@ -14,5 +19,215 @@ func (server *Server) logRequestMiddleware(ctx iris.Context) { path := ctx.Request().URL.Path status := ctx.ResponseWriter().StatusCode() - server.logger.Info("%s %s - Status: %d - Latency: %s", method, path, status, latency) + server.Logger.Info("%s %s - Status: %d - Latency: %s", method, path, status, latency) +} + +func (server *Server) GetProjectsFromToken(ctx iris.Context, jwtHandler middleware.JWTHandler, method string, service string) ([]any, *ErrorResponse) { + Token := ctx.GetHeader("Authorization") + if Token != "" { + anyList, err := jwtHandler.GetAllowedResources(Token, method, service) + if err != nil { + fmt.Println("ERR: ", err) + val, ok := err.(*middleware.ServerError) + if !ok { + return nil, newErrorResponse(fmt.Sprintf("expecting error to be serverError type"), http.StatusNotFound, nil) + + } + return nil, newErrorResponse(val.Message, val.StatusCode, nil) + } + return anyList, nil + } + return nil, newErrorResponse("Auth Token not provided", 401, nil) +} + +func ParseAccess(resourceList []string, resource string, method string) *ErrorResponse { + /* Iterates through a list of Gen3 resoures and returns true if + resource matches the allowable list of resource types for the provided method */ + + if len(resourceList) == 0 { + return newErrorResponse(fmt.Sprintf("User is not allowed to %s on any resource path", method), 403, nil) + } + if slices.Contains(resourceList, resource) { + return nil + } + return newErrorResponse(fmt.Sprintf("User is not allowed to %s on resource path: %s", method, resource), 403, nil) +} + +func convertAnyToStringSlice(anySlice []any) ([]string, *ErrorResponse) { + /* converts []any to []string */ + var stringSlice []string + for _, v := range anySlice { + str, ok := v.(string) + if !ok { + return nil, newErrorResponse(fmt.Sprintf("Element %v is not a string", v), 500, nil) + } + stringSlice = append(stringSlice, str) + } + return stringSlice, nil +} + +func (server *Server) ConfigAuthMiddleware(jwtHandler middleware.JWTHandler) iris.Handler { + return func(ctx iris.Context) { + method := ctx.Method() + configType := ctx.Params().Get("configType") + if configType == "explorer" { + var permMethod string + switch method { + case "GET": + permMethod = "read" + case "PUT", "DELETE": + permMethod = "create" + default: + errResp := newErrorResponse( + fmt.Sprintf("Unsupported HTTP method %s on %s", method, ctx.Request().URL), + http.StatusMethodNotAllowed, nil, + ) + errResp.log.write(server.Logger) + _ = errResp.write(ctx) + return + } + + configID := ctx.Params().Get("configId") + ctx.Params().Set("projectId", configID) + + explorerAuthHandler := server.GeneralAuthMware(jwtHandler, permMethod, "*") + explorerAuthHandler(ctx) + if ctx.IsStopped() { + return + } + ctx.Next() + + } else { + // Non-explorer path + if method == "GET" { + ctx.Next() + return + } + + if method == "PUT" || method == "DELETE" { + baseAuthHandler := server.BaseConfigsAuthMiddleware(jwtHandler, "*", "*", "/programs") + baseAuthHandler(ctx) + + if ctx.IsStopped() { + return + } + ctx.Next() + return + } + + errResp := newErrorResponse( + fmt.Sprintf("Unsupported HTTP method %s on %s", method, ctx.Request().URL), + http.StatusMethodNotAllowed, nil, + ) + errResp.log.write(server.Logger) + _ = errResp.write(ctx) + } + } +} + +func ConfigIDToProjectIDMware(ctx iris.Context) { + configID := ctx.Params().Get("configId") + ctx.Params().Set("projectId", configID) +} + +func (server *Server) GeneralAuthMware(jwtHandler middleware.JWTHandler, method, service string) iris.Handler { + return func(ctx iris.Context) { + authorizationHeader := ctx.GetHeader("Authorization") + if authorizationHeader == "" { + errResponse := newErrorResponse("Authorization token not provided", http.StatusBadRequest, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + ctx.StopExecution() + return + } + + project_split := strings.Split(ctx.Params().Get("projectId"), "-") + if len(project_split) != 2 { + errResponse := newErrorResponse(fmt.Sprintf("Failed to parse request body: incorrect path %s", ctx.Request().URL), http.StatusNotFound, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + ctx.StopExecution() + return + } + + anyList, err := jwtHandler.GetAllowedResources(authorizationHeader, method, service) + if err != nil { + val, ok := err.(*middleware.ServerError) + if !ok { + errResponse := newErrorResponse(fmt.Sprintf("expecting error to be serverError type"), http.StatusNotFound, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + ctx.StopExecution() + return + } + errResponse := newErrorResponse(val.Message, val.StatusCode, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + ctx.StopExecution() + return + } + + resourceList, convErr := convertAnyToStringSlice(anyList) + if convErr != nil { + convErr.log.write(server.Logger) + _ = convErr.write(ctx) + ctx.StopExecution() + return + } + + convErr = ParseAccess(resourceList, "/programs/"+project_split[0]+"/projects/"+project_split[1], method) + if convErr != nil { + convErr.log.write(server.Logger) + _ = convErr.write(ctx) + ctx.StopExecution() + return + } + ctx.Next() + } +} + +func (server *Server) BaseConfigsAuthMiddleware(jwtHandler middleware.JWTHandler, method, service, resourcePath string) iris.Handler { + return func(ctx iris.Context) { + authorizationHeader := ctx.GetHeader("Authorization") + if authorizationHeader == "" { + errResponse := newErrorResponse("Authorization token not provided", http.StatusBadRequest, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + ctx.StopExecution() + return + } + Token := authorizationHeader + prodHandler, ok := jwtHandler.(*middleware.ProdJWTHandler) + if !ok { + errResponse := newErrorResponse("Internal server error: Invalid JWT handler configuration for this route", http.StatusInternalServerError, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + ctx.StopExecution() + return + } + allowed, err := prodHandler.CheckResourceServiceAccess(Token, method, service, resourcePath) + if err != nil { + val, ok := err.(*middleware.ServerError) + if !ok { + errResponse := newErrorResponse(fmt.Sprintf("expecting error to be serverError type"), http.StatusNotFound, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + ctx.StopExecution() + return + } + errResponse := newErrorResponse(val.Message, val.StatusCode, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + ctx.StopExecution() + return + } + if !allowed { + errResponse := newErrorResponse(fmt.Sprintf("User does not have required %s permission on resource %s", method, "/programs"), http.StatusForbidden, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + ctx.StopExecution() + return + } + ctx.Next() + } } diff --git a/gecko/response.go b/gecko/response.go index 9e05a12..ebbb5a9 100644 --- a/gecko/response.go +++ b/gecko/response.go @@ -113,9 +113,9 @@ func newErrorResponse(message string, code int, err *error) *ErrorResponse { response.err = *err } if code >= 500 { - response.log.Error(message) + response.log.Error("%s", message) } else { - response.log.Info(message) + response.log.Info("%s", message) } return response } diff --git a/gecko/server.go b/gecko/server.go index 04435fe..28dda1a 100644 --- a/gecko/server.go +++ b/gecko/server.go @@ -11,6 +11,8 @@ import ( "regexp" "strings" + "github.com/bmeg/grip-graphql/middleware" + "github.com/bmeg/grip/gripql" "github.com/iris-contrib/swagger" "github.com/iris-contrib/swagger/swaggerFiles" "github.com/jmoiron/sqlx" @@ -20,16 +22,18 @@ import ( ) type LogHandler struct { - logger *log.Logger + Logger *log.Logger } type Server struct { - iris *iris.Application - db *sqlx.DB - jwtApp arborist.JWTDecoder - logger *LogHandler - stmts *arborist.CachedStmts - qdrantClient *qdrant.Client + iris *iris.Application + db *sqlx.DB + jwtApp arborist.JWTDecoder + Logger *LogHandler + stmts *arborist.CachedStmts + qdrantClient *qdrant.Client + gripqlClient *gripql.Client + gripGraphName string } func NewServer() *Server { @@ -37,7 +41,7 @@ func NewServer() *Server { } func (server *Server) WithLogger(logger *log.Logger) *Server { - server.logger = &LogHandler{logger: logger} + server.Logger = &LogHandler{Logger: logger} return server } @@ -57,77 +61,107 @@ func (server *Server) WithQdrantClient(client *qdrant.Client) *Server { return server } +func (server *Server) WithGripqlClient(client *gripql.Client, gripGraphName string) *Server { + server.gripqlClient = client + server.gripGraphName = gripGraphName + return server +} + func (server *Server) Init() (*Server, error) { - if server.db == nil { - return nil, errors.New("gecko server initialized without database") - } + if server.jwtApp == nil { return nil, errors.New("gecko server initialized without JWT app") } - if server.logger == nil { + if server.Logger == nil { return nil, errors.New("gecko server initialized without logger") } + if server.db == nil { + server.Logger.Warning("Database endpoints will be disabled.") + } if server.qdrantClient == nil { - return nil, errors.New("gecko server initialized without Qdrant client") + server.Logger.Warning("Qdrant endpoints will be disabled.") + } + if server.gripqlClient == nil || server.gripGraphName == "" { + server.Logger.Warning("Grip endpoints will be disabled.") } - server.logger.Info("Gecko server initialized successfully.") + server.Logger.Info("Gecko server initialized successfully.") return server, nil } func (server *Server) MakeRouter() *iris.Application { router := iris.New() - if router == nil { - server.logger.Error("Failed to initialize router") - } - - // Serve your swagger.json from /swagger/doc.json router.Get("/swagger/doc.json", func(ctx iris.Context) { ctx.ServeFile("./docs/swagger.json") }) - router.Use(recoveryMiddleware) router.Use(server.logRequestMiddleware) router.OnErrorCode(iris.StatusNotFound, handleNotFound) router.Get("/health", server.handleHealth) - router.Get("/config/{configId}", server.handleConfigGET) - router.Put("/config/{configId}", server.handleConfigPUT) - router.Get("/config/list", server.handleConfigListGET) - router.Delete("/config/{configId}", server.handleConfigDELETE) - - vectorRouter := router.Party("/vector") - { - swaggerUI := swagger.Handler(swaggerFiles.Handler, - swagger.URL("/vector/swagger/doc.json"), - swagger.DeepLinking(true), - swagger.Prefix("/vector/swagger"), - ) - vectorRouter.Get("/swagger/doc.json", func(ctx iris.Context) { - ctx.ServeFile("./docs/swagger.json") - }) - vectorRouter.Get("/swagger", swaggerUI) - vectorRouter.Get("/swagger/{any:path}", swaggerUI) - collections := vectorRouter.Party("/collections") + if server.gripqlClient != nil { + router.Get("/dir", server.handleListProjects) + router.Get("/dir/{projectId}", server.GeneralAuthMware(&middleware.ProdJWTHandler{}, "read", "*"), server.handleDirGet) + } else { + server.Logger.Warning("Skipping gripql Directory endpoints — no database configured") + } + + // project id must be in the form [program-project] if not permissions checking will not work and you won't be able to view the project + if server.db != nil { + router.Get("/config/list", server.handleConfigListGET) + router.Get("/config/{configType}/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigGET) + router.Put("/config/{configType}/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigPUT) + router.Delete("/config/{configType}/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigDELETE) + } else { + server.Logger.Warning("Skipping DB endpoints — no database configured") + } + + if server.qdrantClient != nil { + vectorRouter := router.Party("/vector") { - collections.Get("", server.handleListCollections) - collections.Put("/{collection}", server.handleCreateCollection) - collections.Get("/{collection}", server.handleGetCollection) - collections.Patch("/{collection}", server.handleUpdateCollection) - collections.Delete("/{collection}", server.handleDeleteCollection) - points := collections.Party("/{collection}/points") + swaggerUI := swagger.Handler(swaggerFiles.Handler, + swagger.URL("/vector/swagger/doc.json"), + swagger.DeepLinking(true), + swagger.Prefix("/vector/swagger"), + ) + + vectorRouter.Get("/swagger/doc.json", func(ctx iris.Context) { + ctx.ServeFile("./docs/swagger.json") + }) + vectorRouter.Get("/swagger", swaggerUI) + vectorRouter.Get("/swagger/{any:path}", swaggerUI) + + collections := vectorRouter.Party("/collections") { - points.Put("", server.handleUpsertPoints) - points.Get("/{id}", server.handleGetPoint) - points.Post("/search", server.handleQueryPoints) - points.Post("/delete", server.handleDeletePoints) + collections.Get("", server.handleListCollections) + collections.Put("/{collection}", server.handleCreateCollection) + collections.Get("/{collection}", server.handleGetCollection) + collections.Patch("/{collection}", server.handleUpdateCollection) + collections.Delete("/{collection}", server.handleDeleteCollection) + + points := collections.Party("/{collection}/points") + { + points.Put("", server.handleUpsertPoints) + points.Get("/{id}", server.handleGetPoint) + points.Post("/search", server.handleQueryPoints) + points.Post("/delete", server.handleDeletePoints) + } } } + } else { + server.Logger.Warning("Skipping Qdrant endpoints — no vector store configured") } + if server.gripqlClient != nil && server.gripGraphName != "" { + // register your Grip routes here + } else { + server.Logger.Warning("Skipping Grip endpoints — no graph configured") + } + + // Final trim/slash middleware and build router.UseRouter(func(ctx iris.Context) { req := ctx.Request() if req == nil || req.URL == nil { - server.logger.Warning("Request or URL is nil") + server.Logger.Warning("Request or URL is nil") ctx.StatusCode(http.StatusInternalServerError) ctx.WriteString("Internal Server Error") return @@ -137,8 +171,9 @@ func (server *Server) MakeRouter() *iris.Application { }) if err := router.Build(); err != nil { - server.logger.Error("Failed to build Iris router: %v", err) + server.Logger.Error("Failed to build Iris router: %v", err) } + return router } @@ -164,12 +199,12 @@ func recoveryMiddleware(ctx iris.Context) { func (server *Server) handleHealth(ctx iris.Context) { err := server.db.Ping() if err != nil { - server.logger.Error("Database ping failed: %v", err) + server.Logger.Error("Database ping failed: %v", err) response := newErrorResponse("database unavailable", 500, nil) _ = response.write(ctx) return } - server.logger.Info("Health check passed") + server.Logger.Info("Health check passed") _ = jsonResponseFrom("Healthy", http.StatusOK).write(ctx) } diff --git a/gecko/sql.go b/gecko/sql.go index f17ba6a..e51d3e6 100644 --- a/gecko/sql.go +++ b/gecko/sql.go @@ -6,80 +6,119 @@ import ( "errors" "fmt" - "github.com/calypr/gecko/gecko/config" "github.com/jmoiron/sqlx" + "github.com/lib/pq" ) +const configSchema = "config_schema" + +// Document is the generic structure for configuration items in any table. +// Note: 'Name' maps to 'configId' in the request logic. type Document struct { - ID int `db:"id"` Name string `db:"name"` Content json.RawMessage `db:"content"` // Store JSON as raw bytes } -func configList(db *sqlx.DB) ([]string, error) { +// configListByType fetches the list of all 'name' (configId) values from a specific table (configType). +func configListByType(db *sqlx.DB, configType string) ([]string, error) { var names []string - err := db.Select(&names, "SELECT name FROM documents") + // NOTE: configType is validated in the handler against a fixed list, making this safe. + stmt := fmt.Sprintf("SELECT name FROM %s.%s", configSchema, configType) + err := db.Select(&names, stmt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return []string{}, nil } - return nil, fmt.Errorf("error fetching config names: %w", err) + return nil, fmt.Errorf("error fetching config names from table %s: %w", configType, err) } return names, nil } -func configGET(db *sqlx.DB, name string) (map[string]any, error) { - stmt := "SELECT name, content FROM documents WHERE name=$1" +// getDocByIdAndTable fetches the Document struct (ID, Name, Content) by name (configId) from a specific table (configType). +// Returns nil, nil if no rows are found. +func getDocByIdAndTable(db *sqlx.DB, configId string, configType string) (*Document, error) { + stmt := fmt.Sprintf("SELECT name, content FROM %s.%s WHERE name=$1", configSchema, configType) doc := &Document{} - err := db.Get(doc, stmt, name) + + err := db.Get(doc, stmt, configId) if err != nil { if errors.Is(err, sql.ErrNoRows) { + return nil, nil // Standard 404 case (config ID not found) + } + + // Check for "relation does not exist" error code (SQLSTATE 42P01) + var pgErr *pq.Error + if errors.As(err, &pgErr) && pgErr.Code == "42P01" { + // Treat non-existent table (bad configType) as a 404 (resource not found) return nil, nil + // NOTE: The handler logic for nil/nil means "no configs found for this type", + // which can be treated as a 404. } - return nil, err + + // All other errors are true 500s + return nil, fmt.Errorf("error fetching document from table %s: %w", configType, err) } + return doc, nil +} - var content config.Config - err = json.Unmarshal(doc.Content, &content) +// configGETGeneric fetches a document and unmarshals its JSON content into the 'target' struct. +// 'target' must be a pointer to the configuration struct (e.g., *config.AppsPageConfig). +func configGETGeneric(db *sqlx.DB, configId string, configType string, target any) error { + doc, err := getDocByIdAndTable(db, configId, configType) if err != nil { - return nil, err + return err + } + if doc == nil { + return sql.ErrNoRows } - return map[string]any{"content": content, "id": doc.ID, "Name": doc.Name}, nil + err = json.Unmarshal(doc.Content, target) + if err != nil { + return fmt.Errorf("error unmarshalling content for %s from table %s: %w", configId, configType, err) + } + return nil } -func configDELETE(db *sqlx.DB, name string) (bool, error) { - // First, let's check if the config even exists. - stmt := "SELECT name FROM documents WHERE name=$1" - doc := &Document{} - err := db.Get(doc, stmt, name) + +// configPUTGeneric marshals 'data' (any Go struct) and performs an INSERT or UPDATE (upsert) in the specified table. +func configPUTGeneric(db *sqlx.DB, configId string, configType string, data any) error { + jsonData, err := json.Marshal(data) if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return false, nil - } - return false, err + return fmt.Errorf("error marshalling data for %s: %w", configId, err) } - deleteStmt := "DELETE FROM documents WHERE name=$1" - _, err = db.Exec(deleteStmt, name) + // NOTE: configType is validated in the handler against a fixed list, making this safe. + stmt := fmt.Sprintf(` + INSERT INTO %s.%s (name, content) + VALUES ($1, $2) + ON CONFLICT (name) + DO UPDATE SET content = $2; + `, configSchema, configType) + + // $1 is 'configId', $2 is 'jsonData' + _, err = db.Exec(stmt, configId, jsonData) if err != nil { - return false, err + return fmt.Errorf("error executing PUT for %s in table %s: %w", configId, configType, err) } - return true, nil + return nil } -func configPUT(db *sqlx.DB, name string, data config.Config) error { - stmt := ` - INSERT INTO documents (name, content) - VALUES ($1, $2) - ON CONFLICT (name) - DO UPDATE SET content = $2; - ` - jsonData, err := json.Marshal(data) +// configDELETEGeneric deletes a document by name (configId) from the specified table (configType). +// Returns true if deleted, false if not found, or an error. +func configDELETEGeneric(db *sqlx.DB, configId string, configType string) (bool, error) { + // Check existence first + doc, err := getDocByIdAndTable(db, configId, configType) if err != nil { - return err + return false, err } - _, err = db.Exec(stmt, name, jsonData) + if doc == nil { + return false, nil // Not found + } + + // Delete the document + // NOTE: configType is validated in the handler against a fixed list, making this safe. + deleteStmt := fmt.Sprintf("DELETE FROM %s.%s WHERE name=$1", configSchema, configType) + _, err = db.Exec(deleteStmt, configId) if err != nil { - return err + return false, fmt.Errorf("error executing DELETE for %s in table %s: %w", configId, configType, err) } - return nil + return true, nil } diff --git a/go.mod b/go.mod index ff1bd46..c949b19 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,22 @@ module github.com/calypr/gecko -go 1.22.6 +go 1.24.2 require ( + github.com/bmeg/grip v0.0.0-20251106174949-7f0784126fbb + github.com/bmeg/grip-graphql v0.0.0-20251106183540-8b2f286248b3 github.com/google/uuid v1.6.0 github.com/iris-contrib/swagger v0.0.0-20230820002204-56b041d3471a github.com/jmoiron/sqlx v1.4.0 github.com/kataras/iris/v12 v12.2.11 + github.com/lib/pq v1.10.9 github.com/qdrant/go-client v1.15.0 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.11.1 github.com/swaggo/swag v1.16.6 github.com/uc-cdis/arborist v0.0.0-20241016192742-6190d06f1061 github.com/uc-cdis/go-authutils v0.1.2 - google.golang.org/grpc v1.66.0 - google.golang.org/protobuf v1.34.2 + google.golang.org/grpc v1.71.0 + google.golang.org/protobuf v1.36.7 ) require ( @@ -27,6 +30,9 @@ require ( github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06 // indirect github.com/andybalholm/brotli v1.1.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/structs v1.1.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -35,11 +41,14 @@ require ( github.com/go-openapi/jsonreference v0.19.6 // indirect github.com/go-openapi/spec v0.20.4 // indirect github.com/go-openapi/swag v0.19.15 // indirect - github.com/golang/snappy v0.0.4 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e // indirect github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/handlers v1.5.1 // indirect github.com/gorilla/mux v1.8.0 // indirect + github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/iris-contrib/schema v0.0.6 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/kataras/blocks v0.0.8 // indirect @@ -47,31 +56,42 @@ require ( github.com/kataras/pio v0.0.13 // indirect github.com/kataras/sitemap v0.0.6 // indirect github.com/kataras/tunnel v0.0.4 // indirect - github.com/klauspost/compress v1.17.7 // indirect - github.com/lib/pq v1.10.9 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.8 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/logrusorgru/aurora v2.0.3+incompatible // indirect github.com/mailgun/raymond/v2 v2.0.48 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/schollz/closestmatch v2.1.0+incompatible // indirect github.com/sergi/go-diff v1.1.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/tdewolff/minify/v2 v2.20.19 // indirect github.com/tdewolff/parse/v2 v2.7.12 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/yosssi/ace v0.0.5 // indirect - golang.org/x/crypto v0.32.0 // indirect - golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect - golang.org/x/mod v0.17.0 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect + golang.org/x/mod v0.22.0 // indirect + golang.org/x/net v0.37.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/term v0.30.0 // indirect + golang.org/x/text v0.23.0 // indirect golang.org/x/time v0.5.0 // indirect - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed // indirect + golang.org/x/tools v0.28.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 075dbf0..0dc62a5 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 h1:sR+/8Yb4slttB4vD+b9btVEnWgL3Q00OBTzVT8B9C0c= @@ -24,12 +26,36 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1 github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/bmeg/grip v0.0.0-20251015152131-81c3f20ad301 h1:8Wxnterbl6QrjrzOFHMYx5opl5y2jbSHh634VNVivqU= +github.com/bmeg/grip v0.0.0-20251015152131-81c3f20ad301/go.mod h1:YhsmNY+ksx9ohglQWKI+WazwS28uLKPO6ulWS6QLY30= +github.com/bmeg/grip v0.0.0-20251106174949-7f0784126fbb h1:GYQ0Tfj36h8m+6dZolHDQJyVnjjqT3pgBZlFGHT+HOE= +github.com/bmeg/grip v0.0.0-20251106174949-7f0784126fbb/go.mod h1:BxpaUuXbymKkEPvSDslziCzU17akkBo1ubu9nAFsI1A= +github.com/bmeg/grip-graphql v0.0.0-20250924224746-dc7f74b4040f h1:GQxRuotthPMMnds1EpYWlveOShUWuCEKhO3tc1KLYPI= +github.com/bmeg/grip-graphql v0.0.0-20250924224746-dc7f74b4040f/go.mod h1:mbCMMkG3xa2/mhs1pbKWoCb95STPri/bqCJtTn2oIKw= +github.com/bmeg/grip-graphql v0.0.0-20251106183540-8b2f286248b3 h1:rWKYGUcCdStTQxCjKZU1e3/0PioP12WQPchsRZFSe5M= +github.com/bmeg/grip-graphql v0.0.0-20251106183540-8b2f286248b3/go.mod h1:YcZY4w597zXAzi5iA9A48KIRGpnSSCb66ZFyN88LRKA= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= -github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= +github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= @@ -37,6 +63,12 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/flosch/pongo2/v4 v4.0.2 h1:gv+5Pe3vaSVmiJvh/BZa82b7/00YUGm0PIyVVLop0Hw= github.com/flosch/pongo2/v4 v4.0.2/go.mod h1:B5ObFANs/36VwxxlgKpdchIJHMvHB562PW+BWPhwZD8= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= @@ -49,14 +81,26 @@ github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyr github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e h1:4bw4WeyTYPp0smaXiJZCNnLrvVBqirQVreixayXezGc= +github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0 h1:4gjrh/PN2MuWCCElk8/I4OCKRKWCCo2zEct3VKCbibU= github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +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/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -69,6 +113,10 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk= github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= github.com/iris-contrib/httpexpect/v2 v2.15.2 h1:T9THsdP1woyAqKHwjkEsbCnMefsAFvk8iJJKokcJ3Go= @@ -93,15 +141,28 @@ github.com/kataras/sitemap v0.0.6 h1:w71CRMMKYMJh6LR2wTgnk5hSgjVNB9KL60n5e2KHvLY github.com/kataras/sitemap v0.0.6/go.mod h1:dW4dOCNs896OR1HmG+dMLdT7JjDk7mYBzoIRwuj5jA4= github.com/kataras/tunnel v0.0.4 h1:sCAqWuJV7nPzGrlb0os3j49lk2JhILT0rID38NHNLpA= github.com/kataras/tunnel v0.0.4/go.mod h1:9FkU4LaeifdMWqZu7o20ojmW4B7hdhv2CMLwfnHGpYw= -github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= -github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= +github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= +github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/mailgun/raymond/v2 v2.0.48 h1:5dmlB680ZkFG2RN/0lvTAghrSxIESeu9/2aeDqACtjw= github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -111,20 +172,29 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= +github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/qdrant/go-client v1.15.0 h1:4BvoSJSK1mLjGBRhhbwMvG+0+QFkCqG89DZs4NwrGTM= github.com/qdrant/go-client v1.15.0/go.mod h1:iO8ts78jL4x6LDHFOViyYWELVtIBDTjOykBmiOTHLnQ= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sanity-io/litter v1.5.5 h1:iE+sBxPBzoK6uaEP5Lt3fHNgpKcHXc/A2HGETy0uJQo= @@ -133,17 +203,28 @@ github.com/schollz/closestmatch v2.1.0+incompatible h1:Uel2GXEpJqOWBrlyI+oY9LTiy github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/tdewolff/minify/v2 v2.20.19 h1:tX0SR0LUrIqGoLjXnkIzRSIbKJ7PaNnSENLD4CyH6Xo= @@ -153,6 +234,8 @@ github.com/tdewolff/parse/v2 v2.7.12/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo= github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/uc-cdis/arborist v0.0.0-20241016192742-6190d06f1061 h1:OwOYKPYN8Jw7GA2wL0F5gy4EDQiz9ER8JZuUbZZ9i3w= github.com/uc-cdis/arborist v0.0.0-20241016192742-6190d06f1061/go.mod h1:163E0gn2kR7Q2cGswNQZ2ScTUfsYPzk57fEDCtC6Ykc= github.com/uc-cdis/go-authutils v0.1.2 h1:ts9Q1jHs0YIzeErZ6MAsbTrQwfNL4RjE9Wcx/+TFSd0= @@ -177,76 +260,149 @@ github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCO github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8= -golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w= +golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= +golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed h1:J6izYgfBXAI3xTKLgxzTmUltdYaLsuBxFCgDHWJ/eXg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= -google.golang.org/grpc v1.66.0 h1:DibZuoBznOxbDQxRINckZcUvnCEvrW9pcWIE2yF9r1c= -google.golang.org/grpc v1.66.0/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a h1:DMCgtIAIQGZqJXMVzJF4MV8BlWoJh2ZuFiRdAleyr58= +google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a/go.mod h1:y2yVLIE/CSMCPXaHnSKXxu1spLPnglFLegmgdY23uuE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a h1:tPE/Kp+x9dMSwUm/uM0JKK0IfdiJkwAbSMSeZBXXJXc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= moul.io/http2curl/v2 v2.3.0 h1:9r3JfDzWPcbIklMOs2TnIFzDYvfAZvjeavG6EzP7jYs= moul.io/http2curl/v2 v2.3.0/go.mod h1:RW4hyBjTWSYDOxapodpNEtX0g5Eb16sxklBqmd2RHcE= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/init_postgres.sh b/init_postgres.sh index c4874c9..f3f4449 100644 --- a/init_postgres.sh +++ b/init_postgres.sh @@ -16,10 +16,38 @@ GRANT ALL PRIVILEGES ON DATABASE testdb TO postgres; \c testdb; -CREATE TABLE IF NOT EXISTS documents ( - name VARCHAR(255) PRIMARY KEY, - content JSONB -); - +DO \$$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'config_schema') THEN + CREATE SCHEMA config_schema; + END IF; +END \$$; + +CREATE OR REPLACE FUNCTION create_config_table(schema_name TEXT, table_name TEXT) +RETURNS void AS \$$ +BEGIN + EXECUTE format(' + CREATE TABLE IF NOT EXISTS %I.%I ( + name VARCHAR(255) PRIMARY KEY, + content JSONB + ); + ', schema_name, table_name); +END; +\$\$ LANGUAGE plpgsql; + +DO \$$ +DECLARE + config_tables TEXT[] := ARRAY['explorer', 'nav', 'file_summary', 'apps_page']; + table_name TEXT; +BEGIN + FOREACH table_name IN ARRAY config_tables + LOOP + PERFORM create_config_table('config_schema', table_name); + RAISE NOTICE 'Table %.% created successfully.', 'config_schema', table_name; + END LOOP; +END +\$$ LANGUAGE plpgsql; + +DROP FUNCTION create_config_table(TEXT, TEXT); \q EOF +echo "Database initialization complete." diff --git a/main.go b/main.go index 7127ae5..62dd171 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,8 @@ import ( "strconv" "time" + "github.com/bmeg/grip/gripql" + "github.com/bmeg/grip/util/rpc" "github.com/calypr/gecko/gecko" "github.com/jmoiron/sqlx" "github.com/qdrant/go-client/qdrant" @@ -35,15 +37,30 @@ func main() { var qdrantPortFlag = flag.Int("qdrant-port", 0, "Qdrant port (overrides QDRANT_PORT env var)") var qdrantAPIKeyFlag = flag.String("qdrant-api-key", "", "Qdrant API Key (overrides QDRANT_API_KEY env var)") + var gripGraphName = flag.String("grip-graph-zname", "", "The graph name to use when querying Grip (overrides GRIP_GRAPH env var)") + var gripPort = flag.String("grip-port", "", "The rpc port to be used for connecting to Grip (overrides GRIP_PORT env var)") + var gripHost = flag.String("grip-host", "", "The hostname to be usd for connecting to Grip (overrides GRIP_HOST env var)") flag.Parse() + gripGraph := *gripGraphName + if gripGraph == "" { + gripGraph = os.Getenv("GRIP_GRAPH") + } + + gripPortVar := *gripPort + if gripPortVar == "" { + gripPortVar = os.Getenv("GRIP_PORT") + } + + gripHostvar := *gripHost + if gripHostvar == "" { + gripHostvar = os.Getenv("GRIP_HOST") + } + qdrantHost := *qdrantHostFlag if qdrantHost == "" { qdrantHost = os.Getenv("QDRANT_HOST") } - if qdrantHost == "" { - qdrantHost = "localhost" // Final default - } qdrantPort := *qdrantPortFlag if qdrantPort == 0 { @@ -55,9 +72,6 @@ func main() { } } } - if qdrantPort == 0 { - qdrantPort = 6334 // Final default - } qdrantAPIKey := *qdrantAPIKeyFlag if qdrantAPIKey == "" { @@ -71,38 +85,78 @@ func main() { if finalJwkEndpoint == "" { logger.Println("WARNING: no $JWKS_ENDPOINT or --jwks specified; endpoints requiring JWT validation will error") } + jwtApp := authutils.NewJWTApplication(finalJwkEndpoint) + + serverBuilder := gecko.NewServer(). + WithLogger(logger). + WithJWTApp(jwtApp) db, err := sqlx.Open("postgres", *dbUrl) if err != nil { - logger.Fatalf("Failed to connect to database: %v", err) - } - if err = db.Ping(); err != nil { - logger.Fatalf("DB ping failed: %v", err) + logger.Printf("WARNING: Failed to open database connection with URL %s: %v. Database endpoints will not be available.", *dbUrl, err) + } else { + if err = db.Ping(); err != nil { + logger.Printf("WARNING: DB ping failed for URL %s: %v. Database endpoints will not be available.", *dbUrl, err) + db.Close() + } else { + logger.Println("Successfully connected to PostgreSQL database.") + serverBuilder = serverBuilder.WithDB(db) + } } - defer db.Close() - jwtApp := authutils.NewJWTApplication(finalJwkEndpoint) + if qdrantHost != "" && qdrantPort != 0 { + if qdrantHost == "localhost" && *qdrantHostFlag == "" && os.Getenv("QDRANT_HOST") == "" { + // Skip connection attempt if only default values would be used and no flag/env was set + // This logic is slightly complex due to your existing defaults; + // A simpler approach is to only check if the host was explicitly set. + // Let's rely on the user to provide *at least* the host flag if they want Qdrant. + } else { + // Re-apply final defaults only if we decide to connect + if qdrantHost == "" { + qdrantHost = "localhost" // Final default + } + if qdrantPort == 0 { + qdrantPort = 6334 + } + + logger.Printf("Attempting to connect to Qdrant at %s:%d", qdrantHost, qdrantPort) + qdrantConfig := &qdrant.Config{ + Host: qdrantHost, + Port: qdrantPort, + APIKey: qdrantAPIKey, + } - logger.Printf("Connecting to Qdrant at %s:%d", qdrantHost, qdrantPort) - qdrantConfig := &qdrant.Config{ - Host: qdrantHost, - Port: qdrantPort, - APIKey: qdrantAPIKey, + qdrantClient, err := qdrant.NewClient(qdrantConfig) + if err != nil { + logger.Printf("WARNING: Failed to initialize Qdrant client at %s:%d: %v. Qdrant endpoints will not be available.", qdrantHost, qdrantPort, err) + } else { + logger.Println("Successfully connected to Qdrant.") + serverBuilder = serverBuilder.WithQdrantClient(qdrantClient) + } + } + } else { + logger.Println("INFO: Qdrant configuration (--qdrant-host or QDRANT_HOST) not fully specified. Qdrant endpoints will not be available.") } - qdrantClient, err := qdrant.NewClient(qdrantConfig) - if err != nil { - logger.Fatalf("Failed to initialize Qdrant client: %v", err) + if gripHostvar != "" && gripPortVar != "" { + logger.Printf("Attempting to connect to Grip at %s:%s using graph %s", gripHostvar, gripPortVar, gripGraph) + gripqlClient, err := gripql.Connect(rpc.ConfigWithDefaults(gripHostvar+":"+gripPortVar), false) + if err != nil { + logger.Printf("WARNING: Failed to initialize Grip client: %v. Grip endpoints will not be available.", err) + } else { + if gripGraph == "" { + logger.Println("WARNING: Connected to Grip but no --grip-graph-name or GRIP_GRAPH specified. Grip endpoints may fail.") + } + logger.Println("Successfully connected to Grip.") + serverBuilder = serverBuilder.WithGripqlClient(&gripqlClient, gripGraph) + } + } else { + logger.Println("INFO: Grip configuration (--grip-host and --grip-port or environment variables) not fully specified. Grip endpoints will not be available.") } - // 4. Initialize the server. It will now use the correctly configured client. - geckoServer, err := gecko.NewServer(). - WithLogger(logger). - WithJWTApp(jwtApp). - WithDB(db). - WithQdrantClient(qdrantClient). // This client is now correctly configured - Init() + geckoServer, err := serverBuilder.Init() if err != nil { + // Log fatal only if the core server initialization fails, independent of the clients log.Fatalf("Failed to initialize gecko server: %v", err) } diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..7a45493 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,519 @@ +openapi: 3.0.3 +info: + title: Gecko API + description: API for managing configurations and Qdrant vector collections + version: 1.0.0 +servers: + - url: /api + description: Main API server +paths: + /health: + get: + summary: Health check endpoint + operationId: healthCheck + responses: + '200': + description: Server is healthy + content: + application/json: + schema: + type: string + example: Healthy + '500': + description: Database unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /config/{configId}: + get: + summary: Retrieve a specific configuration + operationId: getConfig + parameters: + - name: configId + in: path + required: true + schema: + type: string + responses: + '200': + description: Configuration found + content: + application/json: + schema: + $ref: '#/components/schemas/Config' + '404': + description: Configuration not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + put: + summary: Update or create a configuration + operationId: updateConfig + parameters: + - name: configId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ConfigItem' + responses: + '200': + description: Configuration updated + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: ACCEPTED: config_id + '400': + description: Invalid request body + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + summary: Delete a configuration + operationId: deleteConfig + parameters: + - name: configId + in: path + required: true + schema: + type: string + responses: + '200': + description: Configuration deleted + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: DELETED: config_id + '404': + description: Configuration not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /config/list: + get: + summary: List all configurations + operationId: listConfigs + responses: + '200': + description: List of configurations + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Config' + '404': + description: No configurations found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /vector/collections: + get: + summary: List all Qdrant collections + operationId: listCollections + responses: + '200': + description: List of collections + content: + application/json: + schema: + type: object + properties: + result: + type: array + items: + type: string + status: + type: string + example: ok + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /vector/collections/{collection}: + get: + summary: Get collection information + operationId: getCollection + parameters: + - name: collection + in: path + required: true + schema: + type: string + responses: + '200': + description: Collection information + content: + application/json: + schema: + $ref: '#/components/schemas/CollectionInfo' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + put: + summary: Create a new collection + operationId: createCollection + parameters: + - name: collection + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateCollectionRequest' + responses: + '200': + description: Collection created + content: + application/json: + schema: + type: object + properties: + result: + type: boolean + example: true + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + patch: + summary: Update a collection + operationId: updateCollection + parameters: + - name: collection + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateCollection' + responses: + '200': + description: Collection updated + content: + application/json: + schema: + type: object + properties: + result: + type: boolean + example: true + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + summary: Delete a collection + operationId: deleteCollection + parameters: + - name: collection + in: path + required: true + schema: + type: string + responses: + '200': + description: Collection deleted + content: + application/json: + schema: + type: object + properties: + result: + type: boolean + example: true + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /vector/collections/{collection}/points: + put: + summary: Upsert points in a collection + operationId: upsertPoints + parameters: + - name: collection + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpsertRequest' + responses: + '200': + description: Points upserted + content: + application/json: + schema: + type: object + properties: + result: + type: object + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /vector/collections/{collection}/points/{id}: + get: + summary: Get a specific point + operationId: getPoint + parameters: + - name: collection + in: path + required: true + schema: + type: string + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Point retrieved + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Point' + '400': + description: Invalid UUID or parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Point not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /vector/collections/{collection}/points/search: + post: + summary: Query points in a collection + operationId: queryPoints + parameters: + - name: collection + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/QueryPointsRequest' + responses: + '200': + description: Query results + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Point' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /vector/collections/{collection}/points/delete: + post: + summary: Delete points in a collection + operationId: deletePoints + parameters: + - name: collection + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DeletePoints' + responses: + '200': + description: Points deleted + content: + application/json: + schema: + type: object + properties: + result: + type: boolean + example: true + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' +components: + schemas: + ErrorResponse: + type: object + properties: + error: + type: object + properties: + message: + type: string + code: + type: integer + required: + - error + Config: + type: object + additionalProperties: true + ConfigItem: + type: object + additionalProperties: true + CreateCollectionRequest: + type: object + properties: + vectors: + type: object + additionalProperties: + type: object + properties: + size: + type: integer + distance: + type: string + enum: [Cosine, Euclidean, Dot] + required: + - vectors + CollectionInfo: + type: object + additionalProperties: true + UpdateCollection: + type: object + additionalProperties: true + UpsertRequest: + type: object + additionalProperties: true + Point: + type: object + additionalProperties: true + QueryPointsRequest: + type: object + additionalProperties: true + DeletePoints: + type: object + additionalProperties: true \ No newline at end of file diff --git a/tests/integration/api_test.go b/tests/integration/api_test.go index a370192..3c71233 100644 --- a/tests/integration/api_test.go +++ b/tests/integration/api_test.go @@ -2,18 +2,10 @@ package main import ( "bytes" - "encoding/json" - "fmt" "log" - "math/rand/v2" "net/http" - "strconv" "testing" - "github.com/calypr/gecko/gecko/adapter" - "github.com/calypr/gecko/gecko/config" - "github.com/calypr/gecko/tests/fixtures" - "github.com/google/uuid" "github.com/stretchr/testify/assert" ) @@ -35,580 +27,3 @@ func TestHealthCheck(t *testing.T) { //t.Log("health check resp body: ", body) assert.Contains(t, body, "Healthy") } - -func TestHandleConfigPUT(t *testing.T) { - var configs config.Config - err := json.Unmarshal([]byte(fixtures.TestConfig), &configs) - assert.NoError(t, err) - marshalledJSON, err := json.Marshal(configs) - assert.NoError(t, err) - - resp, err := http.DefaultClient.Do(makeRequest("PUT", "http://localhost:8080/config/123", marshalledJSON)) - assert.NoError(t, err) - assert.NotNil(t, resp) - defer resp.Body.Close() - - buf := new(bytes.Buffer) - _, err = buf.ReadFrom(resp.Body) - assert.NoError(t, err) - - var outData map[string]any - err = json.Unmarshal(buf.Bytes(), &outData) - assert.NoError(t, err) - //t.Log("RESP: ", outData) - - expected200Response := map[string]any{ - "code": float64(200), "message": "ACCEPTED: 123", - } - assert.Equal(t, expected200Response, outData) -} - -func TestHandleConfigPUTInvalidJson(t *testing.T) { - resp, err := http.DefaultClient.Do(makeRequest("PUT", "http://localhost:8080/config/123", []byte("invalid json"))) - assert.NoError(t, err) - assert.NotNil(t, resp) - defer resp.Body.Close() - - buf := new(bytes.Buffer) - _, err = buf.ReadFrom(resp.Body) - assert.NoError(t, err) - - var errData map[string]any - err = json.Unmarshal(buf.Bytes(), &errData) - //t.Log("BYTES: ", string(buf.Bytes())) - assert.NoError(t, err) - - expectedErrorResponse := map[string]any{ - "error": map[string]any{ - "code": float64(400), - "message": "Invalid JSON format", - }, - } - assert.Equal(t, expectedErrorResponse, errData) -} - -func TestHandleConfigPUTInvalidObject(t *testing.T) { - marshalledJSON, err := json.Marshal(map[string]any{"foo": "bar"}) - assert.NoError(t, err) - resp, err := http.DefaultClient.Do(makeRequest("PUT", "http://localhost:8080/config/123", marshalledJSON)) - - assert.NoError(t, err) - assert.NotNil(t, resp) - defer resp.Body.Close() - - buf := new(bytes.Buffer) - _, err = buf.ReadFrom(resp.Body) - assert.NoError(t, err) - - var errData map[string]any - err = json.Unmarshal(buf.Bytes(), &errData) - assert.NoError(t, err) - - //t.Log("BYTES: ", string(buf.Bytes())) - expectedErrorResponse := map[string]any{ - "error": map[string]any{ - "code": float64(400), - "message": "body data unmarshal failed: json: unknown field \"foo\"", - }, - } - assert.Equal(t, expectedErrorResponse, errData) -} - -func TestHandleConfigGET(t *testing.T) { - var configs config.Config - err := json.Unmarshal([]byte(fixtures.TestConfig), &configs) - - payloadBytes, err := json.Marshal(configs) - assert.NoError(t, err) - - _, err = http.DefaultClient.Do(makeRequest("PUT", "http://localhost:8080/config/123", payloadBytes)) - assert.NoError(t, err) - - resp, err := http.DefaultClient.Do(makeRequest("GET", "http://localhost:8080/config/123", nil)) - assert.NoError(t, err) - - buf := new(bytes.Buffer) - buf.ReadFrom(resp.Body) - resp.Body.Close() - var outdata map[string]any - json.Unmarshal(buf.Bytes(), &outdata) - - var Resconfigs config.Config - data, _ := json.Marshal(outdata["content"]) - err = json.Unmarshal(data, &Resconfigs) - assert.NoError(t, err) - - resp.Body.Close() -} - -func TestHandle404ConfigGet(t *testing.T) { - resp, err := http.DefaultClient.Do(makeRequest("GET", "http://localhost:8080/config/404config", nil)) - assert.NoError(t, err) - assert.Equal(t, resp.StatusCode, 404) -} - -func TestHandle404ConfigDelete(t *testing.T) { - resp, err := http.DefaultClient.Do(makeRequest("DELETE", "http://localhost:8080/config/404config", nil)) - assert.NoError(t, err) - assert.Equal(t, resp.StatusCode, 404) -} - -func TestHandleConfigDeleteOK(t *testing.T) { - var configs config.Config - err := json.Unmarshal([]byte(fixtures.TestConfig), &configs) - payloadBytes, err := json.Marshal(configs) - assert.NoError(t, err) - _, err = http.DefaultClient.Do(makeRequest("PUT", "http://localhost:8080/config/testdelete", payloadBytes)) - assert.NoError(t, err) - - resp, err := http.DefaultClient.Do(makeRequest("DELETE", "http://localhost:8080/config/testdelete", nil)) - assert.NoError(t, err) - assert.Equal(t, resp.StatusCode, 200) - - resp, err = http.DefaultClient.Do(makeRequest("GET", "http://localhost:8080/config/testdelete", nil)) - assert.NoError(t, err) - assert.Equal(t, resp.StatusCode, 404) -} - -func ptr[T any](v T) *T { - return &v -} - -func generateRandomFloats(n int) []float32 { - randomFloats := make([]float32, n) - for i := range n { - randomFloats[i] = rand.Float32() - } - return randomFloats -} - -const testCollectionName = "test_collection_gecko" -const vectorEndpoint = "http://localhost:8080/vector/collections" -const queryEndpoint = "http://localhost:8080/vector/collections/%s/points/search" -const VECTOR_NAME = "test_vector" - -func cleanupCollection(t *testing.T, name string) { - t.Helper() - url := fmt.Sprintf("%s/%s", vectorEndpoint, name) - _, err := http.DefaultClient.Do(makeRequest(http.MethodDelete, url, nil)) - if err != nil { - t.Logf("Cleanup (ignorable error): Failed to delete collection %s: %v", name, err) - } -} - -func TestQdrantCollectionWorkflow(t *testing.T) { - cleanupCollection(t, testCollectionName) - pointsEndpoint := fmt.Sprintf("%s/%s/points", vectorEndpoint, testCollectionName) - // Test CreateCollection (PUT /vector/collections/{collection}) - t.Run("CreateCollection_OK", func(t *testing.T) { - url := fmt.Sprintf("%s/%s", vectorEndpoint, testCollectionName) - - // Matches adapter/types.go::CreateCollectionRequest - createPayloadJSON := map[string]any{ - "vectors": map[string]any{ - VECTOR_NAME: map[string]any{ - "size": 128, - "distance": "Cosine", - }, - }, - } - - marshalledJSON, err := json.Marshal(createPayloadJSON) - assert.NoError(t, err) - - resp, err := http.DefaultClient.Do(makeRequest(http.MethodPut, url, marshalledJSON)) - assert.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful collection creation") - - var respData map[string]any - buf := new(bytes.Buffer) - _, _ = buf.ReadFrom(resp.Body) - _ = json.Unmarshal(buf.Bytes(), &respData) - - assert.Equal(t, true, respData["result"], "Expected result: true in response body") - }) - - // Test GetCollectionInfo (GET /vector/collections/{collection}) - t.Run("GetCollection_OK", func(t *testing.T) { - url := fmt.Sprintf("%s/%s", vectorEndpoint, testCollectionName) - resp, err := http.DefaultClient.Do(makeRequest(http.MethodGet, url, nil)) - assert.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for getting collection info") - - var respData map[string]any - buf := new(bytes.Buffer) - _, _ = buf.ReadFrom(resp.Body) - _ = json.Unmarshal(buf.Bytes(), &respData) - - assert.Contains(t, respData, "config", "Response should contain the collection config data") - }) - - // Test ListCollections (GET /vector/collections) - t.Run("ListCollections_OK", func(t *testing.T) { - resp, err := http.DefaultClient.Do(makeRequest(http.MethodGet, vectorEndpoint, nil)) - assert.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for listing collections") - - var respData map[string]any - buf := new(bytes.Buffer) - _, _ = buf.ReadFrom(resp.Body) - _ = json.Unmarshal(buf.Bytes(), &respData) - - collections, ok := respData["result"].([]any) - assert.True(t, ok, "result field should be a list") - assert.True(t, len(collections) > 0) - - }) - - t.Run("UpsertPoints_OK", func(t *testing.T) { - // Matches adapter/types.go::UpsertRequest - upsertPayload := map[string]any{ - "points": []map[string]any{ - { - "id": "c3fb3d5c-e423-46ba-a47a-9ff97b94fc50", - "payload": map[string]any{ - "color": "red", - }, - "vector_name": VECTOR_NAME, - "vector": generateRandomFloats(128), - }, - { - "id": "5eb1d065-e222-4e20-a821-954d518844e7", - "payload": map[string]any{ - "color": "green", - }, - "vector_name": VECTOR_NAME, - "vector": generateRandomFloats(128), - }, - { - "id": "1cf900d5-1799-4baa-ac96-ecf7cfaeb94c", - "payload": map[string]any{ - "color": "blue", - }, - "vector_name": VECTOR_NAME, - "vector": generateRandomFloats(128), - }, - }, - } - - marshalledJSON, err := json.Marshal(upsertPayload) - assert.NoError(t, err) - - resp, err := http.DefaultClient.Do(makeRequest(http.MethodPut, pointsEndpoint, marshalledJSON)) - assert.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful upsert") - - var respData map[string]any - buf := new(bytes.Buffer) - _, err = buf.ReadFrom(resp.Body) - assert.NoError(t, err, "Failed to read response body") - err = json.Unmarshal(buf.Bytes(), &respData) - assert.NoError(t, err, "Failed to unmarshal response") - assert.Equal(t, "Completed", respData["status"], "Expected result: Acknowledged in response body") - }) - - t.Run("GetPoint_OK", func(t *testing.T) { - // Just get the point don't worry about doing a query - pointID := "c3fb3d5c-e423-46ba-a47a-9ff97b94fc50" - url := fmt.Sprintf("%s/%s", pointsEndpoint, pointID) - - resp, err := http.DefaultClient.Do(makeRequest(http.MethodGet, url, nil)) - assert.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for getting the point") - var respData []map[string]any - buf := new(bytes.Buffer) - _, _ = buf.ReadFrom(resp.Body) - _ = json.Unmarshal(buf.Bytes(), &respData) - - assert.NotEmpty(t, respData, "Response should contain the point data") - assert.Equal( - t, - "c3fb3d5c-e423-46ba-a47a-9ff97b94fc50", - respData[0]["id"].(map[string]any)["PointIdOptions"].(map[string]any)["Uuid"], - "Expected point ID to be c3fb3d5c-e423-46ba-a47a-9ff97b94fc50", - ) - }) - - t.Run("QueryPoints_Success", func(t *testing.T) { - url := fmt.Sprintf(queryEndpoint, testCollectionName) - requestBody := adapter.QueryPointsRequest{ - LookupID: ptr("c3fb3d5c-e423-46ba-a47a-9ff97b94fc50"), - Limit: 100, - VectorName: VECTOR_NAME, - WithVector: ptr(true), - } - - bodyBytes, err := json.Marshal(requestBody) - if err != nil { - t.Fatalf("Marshal failed on %#v", requestBody) - } - - resp, err := http.DefaultClient.Do(makeRequest(http.MethodPost, url, bodyBytes)) - assert.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful query") - - var actualResponse []map[string]any - buf := new(bytes.Buffer) - _, err = buf.ReadFrom(resp.Body) - - assert.NoError(t, err, "Failed to read response body") - err = json.Unmarshal(buf.Bytes(), &actualResponse) - //t.Log("RESP TWO: ", buf.String()) - - assert.NoError(t, err, "Failed to unmarshal response") - assert.Len(t, actualResponse, 2) - - }) - - t.Run("QueryPoints_MissingVector_BadRequest", func(t *testing.T) { - url := fmt.Sprintf(queryEndpoint, testCollectionName) - requestBody := adapter.QueryPointsRequest{ - Query: []float32{}, - Limit: 5, - } - bodyBytes, _ := json.Marshal(requestBody) - resp, err := http.DefaultClient.Do(makeRequest(http.MethodPost, url, bodyBytes)) - assert.NoError(t, err) - - defer resp.Body.Close() - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - var errResp map[string]any - err = json.NewDecoder(resp.Body).Decode(&errResp) - assert.NoError(t, err) - assert.Contains(t, errResp["error"].(map[string]any)["message"], "invalid query parameter: ") - }) - - ids := []string{} - t.Run("BulkUpsertPoints_OK", func(t *testing.T) { - // Generate 10 points for bulk upsert - points := []map[string]any{} - for i := range 10 { - id := uuid.NewString() - ids = append(ids, id) - color := "color_" + strconv.Itoa(i%3) // For filtering later - point := map[string]any{ - "id": id, - "payload": map[string]any{"color": color}, - "vector_name": VECTOR_NAME, - "vector": generateRandomFloats(128), - } - points = append(points, point) - } - - upsertPayload := map[string]any{"points": points} - - marshalledJSON, err := json.Marshal(upsertPayload) - assert.NoError(t, err) - - resp, err := http.DefaultClient.Do(makeRequest(http.MethodPut, pointsEndpoint, marshalledJSON)) - assert.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful bulk upsert") - - var respData map[string]any - buf := new(bytes.Buffer) - _, err = buf.ReadFrom(resp.Body) - assert.NoError(t, err, "Failed to read response body") - err = json.Unmarshal(buf.Bytes(), &respData) - assert.NoError(t, err, "Failed to unmarshal response") - assert.Equal(t, "Completed", respData["status"], "Expected status: Completed in response body") - }) - - t.Run("QueryPoints_ByColorFilter_Success", func(t *testing.T) { - url := fmt.Sprintf(queryEndpoint, testCollectionName) - requestBody := adapter.QueryPointsRequest{ - LookupID: ptr(ids[0]), // Use first ID, which has color_0 - Limit: 10, - VectorName: VECTOR_NAME, - WithVector: ptr(true), - WithPayload: ptr(true), - Filter: &adapter.HeadFilter{ - Must: []adapter.IndFilter{ - { - Key: "color", - Match: adapter.MatchFilter{ - Value: "color_0", - }, - }, - }, - }, - } - - bodyBytes, err := json.Marshal(requestBody) - assert.NoError(t, err) - - resp, err := http.DefaultClient.Do(makeRequest(http.MethodPost, url, bodyBytes)) - assert.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful query with color filter") - - var actualResponse []map[string]any - buf := new(bytes.Buffer) - _, err = buf.ReadFrom(resp.Body) - assert.NoError(t, err, "Failed to read response body") - err = json.Unmarshal(buf.Bytes(), &actualResponse) - assert.NoError(t, err, "Failed to unmarshal response") - assert.Len(t, actualResponse, 3, "Expected 3 points with color_0 (4 total, excluding self)") // 4 points have color_0, exclude self - - for _, point := range actualResponse { - payload := point["payload"].(map[string]any) - assert.Equal(t, "color_0", payload["color"], "Expected all returned points to have color_0") - } - }) - - t.Run("QueryPoints_ByVector_Success", func(t *testing.T) { - // First, get a point to extract its vector - pointID := ids[0] // From bulk upsert - getUrl := fmt.Sprintf("%s/%s", pointsEndpoint, pointID) - getResp, err := http.DefaultClient.Do(makeRequest(http.MethodGet, getUrl, nil)) - assert.NoError(t, err) - defer getResp.Body.Close() - - var pointData []map[string]any - buf := new(bytes.Buffer) - _, _ = buf.ReadFrom(getResp.Body) - _ = json.Unmarshal(buf.Bytes(), &pointData) - //t.Log("POINT DATA: ", pointData[0]["vectors"].(map[string]any)["VectorsOptions"].(map[string]any)["Vectors"].(map[string]any)["vectors"].(map[string]any)[VECTOR_NAME].(map[string]any)) - vectorMap := pointData[0]["vectors"].(map[string]any)["VectorsOptions"].(map[string]any)["Vectors"].(map[string]any)["vectors"].(map[string]any)[VECTOR_NAME].(map[string]any) - vector := vectorMap["data"].([]any) - queryVector := make([]float32, len(vector)) - for i, v := range vector { - queryVector[i] = float32(v.(float64)) - } - - url := fmt.Sprintf(queryEndpoint, testCollectionName) - requestBody := adapter.QueryPointsRequest{ - Query: queryVector, - Limit: 10, - VectorName: VECTOR_NAME, - WithVector: ptr(true), - } - - bodyBytes, err := json.Marshal(requestBody) - assert.NoError(t, err) - - resp, err := http.DefaultClient.Do(makeRequest(http.MethodPost, url, bodyBytes)) - assert.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful vector query") - - var actualResponse []map[string]any - buf = new(bytes.Buffer) - _, err = buf.ReadFrom(resp.Body) - assert.NoError(t, err) - err = json.Unmarshal(buf.Bytes(), &actualResponse) - assert.NoError(t, err) - assert.Len(t, actualResponse, 10) - assert.GreaterOrEqual(t, actualResponse[0]["score"], float64(0.9999)) - }) - - t.Run("QueryPoints_BySingleID_Success", func(t *testing.T) { - url := fmt.Sprintf(queryEndpoint, testCollectionName) - requestBody := adapter.QueryPointsRequest{ - LookupID: ptr(ids[0]), - Limit: 10, - VectorName: VECTOR_NAME, - WithVector: ptr(true), - } - - bodyBytes, err := json.Marshal(requestBody) - assert.NoError(t, err) - - resp, err := http.DefaultClient.Do(makeRequest(http.MethodPost, url, bodyBytes)) - assert.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful single ID query") - - var actualResponse []map[string]any - buf := new(bytes.Buffer) - _, err = buf.ReadFrom(resp.Body) - assert.NoError(t, err) - err = json.Unmarshal(buf.Bytes(), &actualResponse) - //t.Log("actual RESpnse: ", actualResponse) - assert.NoError(t, err) - assert.Len(t, actualResponse, 10) - }) - - t.Run("QueryPoints_ByMultipleIDs_Success", func(t *testing.T) { - url := fmt.Sprintf(queryEndpoint, testCollectionName) - requestBody := adapter.QueryPointsRequest{ - Positives: []string{ids[0], ids[1]}, - Negatives: []string{ids[9]}, - Limit: 7, - VectorName: VECTOR_NAME, - WithVector: ptr(true), - } - - bodyBytes, err := json.Marshal(requestBody) - assert.NoError(t, err) - - resp, err := http.DefaultClient.Do(makeRequest(http.MethodPost, url, bodyBytes)) - assert.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful multi ID query") - - var actualResponse []map[string]any - buf := new(bytes.Buffer) - _, err = buf.ReadFrom(resp.Body) - assert.NoError(t, err) - err = json.Unmarshal(buf.Bytes(), &actualResponse) - assert.NoError(t, err) - assert.Len(t, actualResponse, 7) // 10 total, excludes 3 used in positives/negatives - }) - - t.Run("DeletePoints_OK", func(t *testing.T) { - // Matches adapter/types.go::DeletePoints - deletePayloadJSON := map[string]any{ - "points": []string{"c3fb3d5c-e423-46ba-a47a-9ff97b94fc50"}, - } - marshalledJSON, err := json.Marshal(deletePayloadJSON) - assert.NoError(t, err) - - resp, err := http.DefaultClient.Do(makeRequest(http.MethodPost, fmt.Sprintf("%s/delete", pointsEndpoint), marshalledJSON)) - assert.NoError(t, err) - defer resp.Body.Close() - - var respData map[string]any - buf := new(bytes.Buffer) - _, err = buf.ReadFrom(resp.Body) - _ = json.Unmarshal(buf.Bytes(), &respData) - - assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful delete") - - pointID := "c3fb3d5c-e423-46ba-a47a-9ff97b94fc50" - url := fmt.Sprintf("%s/%s", pointsEndpoint, pointID) - resp, err = http.DefaultClient.Do(makeRequest(http.MethodGet, url, nil)) - assert.NoError(t, err, "WHAT IS RESP: %v", resp.Status) - defer resp.Body.Close() - - assert.Equal(t, http.StatusNotFound, resp.StatusCode, "Expected 404 Not Found after deleting point") - }) - - t.Run("DeleteCollection_OK", func(t *testing.T) { - url := fmt.Sprintf("%s/%s", vectorEndpoint, testCollectionName) - resp, err := http.DefaultClient.Do(makeRequest(http.MethodDelete, url, nil)) - assert.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful deletion") - - resp, err = http.DefaultClient.Do(makeRequest(http.MethodGet, url, nil)) - assert.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, http.StatusNotFound, resp.StatusCode, "Expected an error (e.g. 500) after deleting collection and trying to GET it") - }) - -} diff --git a/tests/integration/middleware_test.go b/tests/integration/middleware_test.go new file mode 100644 index 0000000..7fd4cf3 --- /dev/null +++ b/tests/integration/middleware_test.go @@ -0,0 +1,219 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/bmeg/grip-graphql/middleware" + "github.com/calypr/gecko/gecko" + "github.com/kataras/iris/v12" + "github.com/stretchr/testify/assert" +) + +type MockJWTHandler struct { + AllowedResources []string + Err error +} + +func (m *MockJWTHandler) GetAllowedResources(token string, method, service string) ([]any, error) { + if m.Err != nil { + return nil, m.Err + } + out := make([]any, len(m.AllowedResources)) + for i, s := range m.AllowedResources { + out[i] = s + } + return out, nil +} + +func (m *MockJWTHandler) CheckResourceServiceAccess(token, resource, service, method string) (bool, error) { + if m.Err != nil { + return false, m.Err + } + for _, r := range m.AllowedResources { + if r == resource { + return true, nil + } + } + return false, nil +} + +func setupServer() *gecko.Server { + return &gecko.Server{ + Logger: &gecko.LogHandler{Logger: log.New(os.Stdout, "", log.Ldate|log.Ltime)}, + } +} + +func TestGeneralAuthMware_NoAuthorization(t *testing.T) { + mockJWT := &MockJWTHandler{} + srv := setupServer() + mware := srv.GeneralAuthMware(mockJWT, "read", "*") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + app := iris.New() + ctx := app.ContextPool.Acquire(rec, req) + ctx.Params().Set("projectId", "ohsu-test") + + mware(ctx) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "Authorization token not provided") +} + +func TestGeneralAuthMware_BadProjectID(t *testing.T) { + mockJWT := &MockJWTHandler{AllowedResources: []string{"/programs/ohsu/projects/test"}} + srv := setupServer() + mware := srv.GeneralAuthMware(mockJWT, "read", "*") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer dummy") + rec := httptest.NewRecorder() + app := iris.New() + ctx := app.ContextPool.Acquire(rec, req) + ctx.Params().Set("projectId", "ohsu") // missing '-' + + mware(ctx) + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.Contains(t, rec.Body.String(), "Failed to parse request body") +} + +func TestGeneralAuthMware_GetAllowedResourcesNonServerError(t *testing.T) { + mockJWT := &MockJWTHandler{ + Err: fmt.Errorf("generic error"), + } + srv := setupServer() + mware := srv.GeneralAuthMware(mockJWT, "read", "*") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer dummy") + rec := httptest.NewRecorder() + app := iris.New() + ctx := app.ContextPool.Acquire(rec, req) + ctx.Params().Set("projectId", "ohsu-test") + + mware(ctx) + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.Contains(t, rec.Body.String(), "expecting error to be serverError type") +} + +func TestGeneralAuthMware_GetAllowedResourcesServerError(t *testing.T) { + mockJWT := &MockJWTHandler{ + Err: &middleware.ServerError{ + Message: "token expired", + StatusCode: http.StatusUnauthorized, + }, + } + srv := setupServer() + mware := srv.GeneralAuthMware(mockJWT, "read", "*") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer dummy") + rec := httptest.NewRecorder() + app := iris.New() + ctx := app.ContextPool.Acquire(rec, req) + ctx.Params().Set("projectId", "ohsu-test") + + mware(ctx) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Contains(t, rec.Body.String(), "token expired") +} + +type MockJWTHandlerBadAny struct{} + +func (m *MockJWTHandlerBadAny) GetAllowedResources(token string, method, service string) ([]any, error) { + return []any{123}, nil // triggers convertAnyToStringSlice error +} + +func (m *MockJWTHandlerBadAny) CheckResourceServiceAccess(token, resource, service, method string) (bool, error) { + return true, nil +} + +// Then in your test: +func TestGeneralAuthMware_ConvertAnyToStringSliceError(t *testing.T) { + mockJWT := &MockJWTHandlerBadAny{} + srv := setupServer() + mware := srv.GeneralAuthMware(mockJWT, "read", "*") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer dummy") + rec := httptest.NewRecorder() + app := iris.New() + ctx := app.ContextPool.Acquire(rec, req) + ctx.Params().Set("projectId", "ohsu-test") + + mware(ctx) + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Contains(t, rec.Body.String(), "Element 123 is not a string") +} + +func TestGeneralAuthMware_ParseAccessDenied(t *testing.T) { + mockJWT := &MockJWTHandler{ + AllowedResources: []string{"/programs/other/projects/test"}, + } + srv := setupServer() + mware := srv.GeneralAuthMware(mockJWT, "read", "*") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer dummy") + rec := httptest.NewRecorder() + app := iris.New() + ctx := app.ContextPool.Acquire(rec, req) + ctx.Params().Set("projectId", "ohsu-test") + + mware(ctx) + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Contains(t, rec.Body.String(), "User is not allowed to read on resource path") +} + +func TestConfigAuthMiddleware_MethodNotAllowed(t *testing.T) { + mockJWT := &MockJWTHandler{} + srv := setupServer() + cfgMware := srv.ConfigAuthMiddleware(mockJWT) + + req := httptest.NewRequest(http.MethodPatch, "/configs/cbds-XYZ?configType=explorer", nil) + req.Header.Set("Authorization", "Bearer dummy") + rec := httptest.NewRecorder() + app := iris.New() + ctx := app.ContextPool.Acquire(rec, req) + ctx.Params().Set("configId", "ohsu-test") + ctx.Params().Set("configType", "explorer") + + cfgMware(ctx) + assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) + assert.Contains(t, rec.Body.String(), "Unsupported HTTP method") +} + +func TestBaseConfigsAuthMiddleware_NoAuthorization(t *testing.T) { + mockJWT := &MockJWTHandler{} + srv := setupServer() + mware := srv.BaseConfigsAuthMiddleware(mockJWT, "read", "*", "/programs") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + app := iris.New() + ctx := app.ContextPool.Acquire(rec, req) + + mware(ctx) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "Authorization token not provided") +} + +func TestBaseConfigsAuthMiddleware_InvalidJWTHandler(t *testing.T) { + mockJWT := &MockJWTHandler{} + srv := setupServer() + mware := srv.BaseConfigsAuthMiddleware(mockJWT, "read", "*", "/programs") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer dummy") + rec := httptest.NewRecorder() + app := iris.New() + ctx := app.ContextPool.Acquire(rec, req) + + mware(ctx) + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Contains(t, rec.Body.String(), "Invalid JWT handler configuration") +} diff --git a/tests/integration/vector_test.go b/tests/integration/vector_test.go new file mode 100644 index 0000000..d99f120 --- /dev/null +++ b/tests/integration/vector_test.go @@ -0,0 +1,458 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "math/rand/v2" + "net/http" + "strconv" + "testing" + + "github.com/calypr/gecko/gecko/adapter" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func ptr[T any](v T) *T { + return &v +} + +func generateRandomFloats(n int) []float32 { + randomFloats := make([]float32, n) + for i := range n { + randomFloats[i] = rand.Float32() + } + return randomFloats +} + +const testCollectionName = "test_collection_gecko" +const vectorEndpoint = "http://localhost:8080/vector/collections" +const queryEndpoint = "http://localhost:8080/vector/collections/%s/points/search" +const VECTOR_NAME = "test_vector" + +func cleanupCollection(t *testing.T, name string) { + t.Helper() + url := fmt.Sprintf("%s/%s", vectorEndpoint, name) + _, err := http.DefaultClient.Do(makeRequest(http.MethodDelete, url, nil)) + if err != nil { + t.Logf("Cleanup (ignorable error): Failed to delete collection %s: %v", name, err) + } +} + +func TestQdrantCollectionWorkflow(t *testing.T) { + cleanupCollection(t, testCollectionName) + pointsEndpoint := fmt.Sprintf("%s/%s/points", vectorEndpoint, testCollectionName) + // Test CreateCollection (PUT /vector/collections/{collection}) + t.Run("CreateCollection_OK", func(t *testing.T) { + url := fmt.Sprintf("%s/%s", vectorEndpoint, testCollectionName) + + // Matches adapter/types.go::CreateCollectionRequest + createPayloadJSON := map[string]any{ + "vectors": map[string]any{ + VECTOR_NAME: map[string]any{ + "size": 128, + "distance": "Cosine", + }, + }, + } + + marshalledJSON, err := json.Marshal(createPayloadJSON) + assert.NoError(t, err) + + resp, err := http.DefaultClient.Do(makeRequest(http.MethodPut, url, marshalledJSON)) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful collection creation") + + var respData map[string]any + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(resp.Body) + _ = json.Unmarshal(buf.Bytes(), &respData) + + assert.Equal(t, true, respData["result"], "Expected result: true in response body") + }) + + // Test GetCollectionInfo (GET /vector/collections/{collection}) + t.Run("GetCollection_OK", func(t *testing.T) { + url := fmt.Sprintf("%s/%s", vectorEndpoint, testCollectionName) + resp, err := http.DefaultClient.Do(makeRequest(http.MethodGet, url, nil)) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for getting collection info") + + var respData map[string]any + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(resp.Body) + _ = json.Unmarshal(buf.Bytes(), &respData) + + assert.Contains(t, respData, "config", "Response should contain the collection config data") + }) + + // Test ListCollections (GET /vector/collections) + t.Run("ListCollections_OK", func(t *testing.T) { + resp, err := http.DefaultClient.Do(makeRequest(http.MethodGet, vectorEndpoint, nil)) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for listing collections") + + var respData map[string]any + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(resp.Body) + _ = json.Unmarshal(buf.Bytes(), &respData) + + collections, ok := respData["result"].([]any) + assert.True(t, ok, "result field should be a list") + assert.True(t, len(collections) > 0) + + }) + + t.Run("UpsertPoints_OK", func(t *testing.T) { + // Matches adapter/types.go::UpsertRequest + upsertPayload := map[string]any{ + "points": []map[string]any{ + { + "id": "c3fb3d5c-e423-46ba-a47a-9ff97b94fc50", + "payload": map[string]any{ + "color": "red", + }, + "vector_name": VECTOR_NAME, + "vector": generateRandomFloats(128), + }, + { + "id": "5eb1d065-e222-4e20-a821-954d518844e7", + "payload": map[string]any{ + "color": "green", + }, + "vector_name": VECTOR_NAME, + "vector": generateRandomFloats(128), + }, + { + "id": "1cf900d5-1799-4baa-ac96-ecf7cfaeb94c", + "payload": map[string]any{ + "color": "blue", + }, + "vector_name": VECTOR_NAME, + "vector": generateRandomFloats(128), + }, + }, + } + + marshalledJSON, err := json.Marshal(upsertPayload) + assert.NoError(t, err) + + resp, err := http.DefaultClient.Do(makeRequest(http.MethodPut, pointsEndpoint, marshalledJSON)) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful upsert") + + var respData map[string]any + buf := new(bytes.Buffer) + _, err = buf.ReadFrom(resp.Body) + assert.NoError(t, err, "Failed to read response body") + err = json.Unmarshal(buf.Bytes(), &respData) + assert.NoError(t, err, "Failed to unmarshal response") + assert.Equal(t, "Completed", respData["status"], "Expected result: Acknowledged in response body") + }) + + t.Run("GetPoint_OK", func(t *testing.T) { + // Just get the point don't worry about doing a query + pointID := "c3fb3d5c-e423-46ba-a47a-9ff97b94fc50" + url := fmt.Sprintf("%s/%s", pointsEndpoint, pointID) + + resp, err := http.DefaultClient.Do(makeRequest(http.MethodGet, url, nil)) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for getting the point") + var respData []map[string]any + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(resp.Body) + _ = json.Unmarshal(buf.Bytes(), &respData) + + assert.NotEmpty(t, respData, "Response should contain the point data") + assert.Equal( + t, + "c3fb3d5c-e423-46ba-a47a-9ff97b94fc50", + respData[0]["id"].(map[string]any)["PointIdOptions"].(map[string]any)["Uuid"], + "Expected point ID to be c3fb3d5c-e423-46ba-a47a-9ff97b94fc50", + ) + }) + + t.Run("QueryPoints_Success", func(t *testing.T) { + url := fmt.Sprintf(queryEndpoint, testCollectionName) + requestBody := adapter.QueryPointsRequest{ + LookupID: ptr("c3fb3d5c-e423-46ba-a47a-9ff97b94fc50"), + Limit: 100, + VectorName: VECTOR_NAME, + WithVector: ptr(true), + } + + bodyBytes, err := json.Marshal(requestBody) + if err != nil { + t.Fatalf("Marshal failed on %#v", requestBody) + } + + resp, err := http.DefaultClient.Do(makeRequest(http.MethodPost, url, bodyBytes)) + assert.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful query") + + var actualResponse []map[string]any + buf := new(bytes.Buffer) + _, err = buf.ReadFrom(resp.Body) + + assert.NoError(t, err, "Failed to read response body") + err = json.Unmarshal(buf.Bytes(), &actualResponse) + //t.Log("RESP TWO: ", buf.String()) + + assert.NoError(t, err, "Failed to unmarshal response") + assert.Len(t, actualResponse, 2) + + }) + + t.Run("QueryPoints_MissingVector_BadRequest", func(t *testing.T) { + url := fmt.Sprintf(queryEndpoint, testCollectionName) + requestBody := adapter.QueryPointsRequest{ + Query: []float32{}, + Limit: 5, + } + bodyBytes, _ := json.Marshal(requestBody) + resp, err := http.DefaultClient.Do(makeRequest(http.MethodPost, url, bodyBytes)) + assert.NoError(t, err) + + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + var errResp map[string]any + err = json.NewDecoder(resp.Body).Decode(&errResp) + assert.NoError(t, err) + assert.Contains(t, errResp["error"].(map[string]any)["message"], "invalid query parameter: ") + }) + + ids := []string{} + t.Run("BulkUpsertPoints_OK", func(t *testing.T) { + // Generate 10 points for bulk upsert + points := []map[string]any{} + for i := range 10 { + id := uuid.NewString() + ids = append(ids, id) + color := "color_" + strconv.Itoa(i%3) // For filtering later + point := map[string]any{ + "id": id, + "payload": map[string]any{"color": color}, + "vector_name": VECTOR_NAME, + "vector": generateRandomFloats(128), + } + points = append(points, point) + } + + upsertPayload := map[string]any{"points": points} + + marshalledJSON, err := json.Marshal(upsertPayload) + assert.NoError(t, err) + + resp, err := http.DefaultClient.Do(makeRequest(http.MethodPut, pointsEndpoint, marshalledJSON)) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful bulk upsert") + + var respData map[string]any + buf := new(bytes.Buffer) + _, err = buf.ReadFrom(resp.Body) + assert.NoError(t, err, "Failed to read response body") + err = json.Unmarshal(buf.Bytes(), &respData) + assert.NoError(t, err, "Failed to unmarshal response") + assert.Equal(t, "Completed", respData["status"], "Expected status: Completed in response body") + }) + + t.Run("QueryPoints_ByColorFilter_Success", func(t *testing.T) { + url := fmt.Sprintf(queryEndpoint, testCollectionName) + requestBody := adapter.QueryPointsRequest{ + LookupID: ptr(ids[0]), // Use first ID, which has color_0 + Limit: 10, + VectorName: VECTOR_NAME, + WithVector: ptr(true), + WithPayload: ptr(true), + Filter: &adapter.HeadFilter{ + Must: []adapter.IndFilter{ + { + Key: "color", + Match: adapter.MatchFilter{ + Value: "color_0", + }, + }, + }, + }, + } + + bodyBytes, err := json.Marshal(requestBody) + assert.NoError(t, err) + + resp, err := http.DefaultClient.Do(makeRequest(http.MethodPost, url, bodyBytes)) + assert.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful query with color filter") + + var actualResponse []map[string]any + buf := new(bytes.Buffer) + _, err = buf.ReadFrom(resp.Body) + assert.NoError(t, err, "Failed to read response body") + err = json.Unmarshal(buf.Bytes(), &actualResponse) + assert.NoError(t, err, "Failed to unmarshal response") + assert.Len(t, actualResponse, 3, "Expected 3 points with color_0 (4 total, excluding self)") // 4 points have color_0, exclude self + + for _, point := range actualResponse { + payload := point["payload"].(map[string]any) + assert.Equal(t, "color_0", payload["color"], "Expected all returned points to have color_0") + } + }) + + t.Run("QueryPoints_ByVector_Success", func(t *testing.T) { + // First, get a point to extract its vector + pointID := ids[0] // From bulk upsert + getUrl := fmt.Sprintf("%s/%s", pointsEndpoint, pointID) + getResp, err := http.DefaultClient.Do(makeRequest(http.MethodGet, getUrl, nil)) + assert.NoError(t, err) + defer getResp.Body.Close() + + var pointData []map[string]any + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(getResp.Body) + _ = json.Unmarshal(buf.Bytes(), &pointData) + //t.Log("POINT DATA: ", pointData[0]["vectors"].(map[string]any)["VectorsOptions"].(map[string]any)["Vectors"].(map[string]any)["vectors"].(map[string]any)[VECTOR_NAME].(map[string]any)) + vectorMap := pointData[0]["vectors"].(map[string]any)["VectorsOptions"].(map[string]any)["Vectors"].(map[string]any)["vectors"].(map[string]any)[VECTOR_NAME].(map[string]any) + vector := vectorMap["data"].([]any) + queryVector := make([]float32, len(vector)) + for i, v := range vector { + queryVector[i] = float32(v.(float64)) + } + + url := fmt.Sprintf(queryEndpoint, testCollectionName) + requestBody := adapter.QueryPointsRequest{ + Query: queryVector, + Limit: 10, + VectorName: VECTOR_NAME, + WithVector: ptr(true), + } + + bodyBytes, err := json.Marshal(requestBody) + assert.NoError(t, err) + + resp, err := http.DefaultClient.Do(makeRequest(http.MethodPost, url, bodyBytes)) + assert.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful vector query") + + var actualResponse []map[string]any + buf = new(bytes.Buffer) + _, err = buf.ReadFrom(resp.Body) + assert.NoError(t, err) + err = json.Unmarshal(buf.Bytes(), &actualResponse) + assert.NoError(t, err) + assert.Len(t, actualResponse, 10) + assert.GreaterOrEqual(t, actualResponse[0]["score"], float64(0.9999)) + }) + + t.Run("QueryPoints_BySingleID_Success", func(t *testing.T) { + url := fmt.Sprintf(queryEndpoint, testCollectionName) + requestBody := adapter.QueryPointsRequest{ + LookupID: ptr(ids[0]), + Limit: 10, + VectorName: VECTOR_NAME, + WithVector: ptr(true), + } + + bodyBytes, err := json.Marshal(requestBody) + assert.NoError(t, err) + + resp, err := http.DefaultClient.Do(makeRequest(http.MethodPost, url, bodyBytes)) + assert.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful single ID query") + + var actualResponse []map[string]any + buf := new(bytes.Buffer) + _, err = buf.ReadFrom(resp.Body) + assert.NoError(t, err) + err = json.Unmarshal(buf.Bytes(), &actualResponse) + //t.Log("actual RESpnse: ", actualResponse) + assert.NoError(t, err) + assert.Len(t, actualResponse, 10) + }) + + t.Run("QueryPoints_ByMultipleIDs_Success", func(t *testing.T) { + url := fmt.Sprintf(queryEndpoint, testCollectionName) + requestBody := adapter.QueryPointsRequest{ + Positives: []string{ids[0], ids[1]}, + Negatives: []string{ids[9]}, + Limit: 7, + VectorName: VECTOR_NAME, + WithVector: ptr(true), + } + + bodyBytes, err := json.Marshal(requestBody) + assert.NoError(t, err) + + resp, err := http.DefaultClient.Do(makeRequest(http.MethodPost, url, bodyBytes)) + assert.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful multi ID query") + + var actualResponse []map[string]any + buf := new(bytes.Buffer) + _, err = buf.ReadFrom(resp.Body) + assert.NoError(t, err) + err = json.Unmarshal(buf.Bytes(), &actualResponse) + assert.NoError(t, err) + assert.Len(t, actualResponse, 7) // 10 total, excludes 3 used in positives/negatives + }) + + t.Run("DeletePoints_OK", func(t *testing.T) { + // Matches adapter/types.go::DeletePoints + deletePayloadJSON := map[string]any{ + "points": []string{"c3fb3d5c-e423-46ba-a47a-9ff97b94fc50"}, + } + marshalledJSON, err := json.Marshal(deletePayloadJSON) + assert.NoError(t, err) + + resp, err := http.DefaultClient.Do(makeRequest(http.MethodPost, fmt.Sprintf("%s/delete", pointsEndpoint), marshalledJSON)) + assert.NoError(t, err) + defer resp.Body.Close() + + var respData map[string]any + buf := new(bytes.Buffer) + _, err = buf.ReadFrom(resp.Body) + _ = json.Unmarshal(buf.Bytes(), &respData) + + assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful delete") + + pointID := "c3fb3d5c-e423-46ba-a47a-9ff97b94fc50" + url := fmt.Sprintf("%s/%s", pointsEndpoint, pointID) + resp, err = http.DefaultClient.Do(makeRequest(http.MethodGet, url, nil)) + assert.NoError(t, err, "WHAT IS RESP: %v", resp.Status) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode, "Expected 404 Not Found after deleting point") + }) + + t.Run("DeleteCollection_OK", func(t *testing.T) { + url := fmt.Sprintf("%s/%s", vectorEndpoint, testCollectionName) + resp, err := http.DefaultClient.Do(makeRequest(http.MethodDelete, url, nil)) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful deletion") + + resp, err = http.DefaultClient.Do(makeRequest(http.MethodGet, url, nil)) + assert.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusNotFound, resp.StatusCode, "Expected an error (e.g. 500) after deleting collection and trying to GET it") + }) + +}