diff --git a/charts/lfx-v2-query-service/Chart.yaml b/charts/lfx-v2-query-service/Chart.yaml index 92b27fc..f7fb650 100644 --- a/charts/lfx-v2-query-service/Chart.yaml +++ b/charts/lfx-v2-query-service/Chart.yaml @@ -5,5 +5,5 @@ apiVersion: v2 name: lfx-v2-query-service description: LFX Platform V2 Query Service chart type: application -version: 0.4.0 +version: 0.4.1 appVersion: "latest" diff --git a/charts/lfx-v2-query-service/templates/httproute.yaml b/charts/lfx-v2-query-service/templates/httproute.yaml index a59a59d..b4922b6 100644 --- a/charts/lfx-v2-query-service/templates/httproute.yaml +++ b/charts/lfx-v2-query-service/templates/httproute.yaml @@ -15,7 +15,7 @@ spec: rules: - matches: - path: - type: Exact + type: PathPrefix value: /query/orgs {{- if .Values.heimdall.enabled }} filters: @@ -25,7 +25,7 @@ spec: kind: Middleware name: heimdall {{- if .Values.orgSearch.rateLimit.enabled }} - # Organization search endpoint (with rate limiting + authentication) + # Organization endpoints (with rate limiting + authentication) - type: ExtensionRef extensionRef: group: traefik.io diff --git a/charts/lfx-v2-query-service/templates/ruleset.yaml b/charts/lfx-v2-query-service/templates/ruleset.yaml index cbafce4..fd6ad92 100644 --- a/charts/lfx-v2-query-service/templates/ruleset.yaml +++ b/charts/lfx-v2-query-service/templates/ruleset.yaml @@ -61,3 +61,19 @@ spec: config: values: aud: lfx-v2-query-service + - id: "rule:lfx:lfx-v2-query-service:org-suggest" + match: + methods: + - GET + routes: + - path: /query/orgs/suggest + execute: + - authenticator: oidc + {{- if .Values.app.use_oidc_contextualizer }} + - contextualizer: oidc_contextualizer + {{- end }} + - authorizer: allow_all + - finalizer: create_jwt + config: + values: + aud: lfx-v2-query-service diff --git a/cmd/service/converters.go b/cmd/service/converters.go index 721c83e..0de1ca2 100644 --- a/cmd/service/converters.go +++ b/cmd/service/converters.go @@ -98,3 +98,31 @@ func (s *querySvcsrvc) domainOrganizationToResponse(org *model.Organization) *qu Employees: &org.Employees, } } + +// payloadToOrganizationSuggestionCriteria converts the generated payload to domain organization suggestion criteria +func (s *querySvcsrvc) payloadToOrganizationSuggestionCriteria(ctx context.Context, p *querysvc.SuggestOrgsPayload) model.OrganizationSuggestionCriteria { + criteria := model.OrganizationSuggestionCriteria{ + Query: p.Query, + } + return criteria +} + +// domainOrganizationSuggestionsToResponse converts domain organization suggestions result to generated response +func (s *querySvcsrvc) domainOrganizationSuggestionsToResponse(result *model.OrganizationSuggestionsResult) *querysvc.SuggestOrgsResult { + if result == nil || len(result.Suggestions) == 0 { + return &querysvc.SuggestOrgsResult{Suggestions: []*querysvc.OrganizationSuggestion{}} + } + suggestions := make([]*querysvc.OrganizationSuggestion, len(result.Suggestions)) + + for i, domainSuggestion := range result.Suggestions { + suggestions[i] = &querysvc.OrganizationSuggestion{ + Name: domainSuggestion.Name, + Domain: domainSuggestion.Domain, + Logo: domainSuggestion.Logo, + } + } + + return &querysvc.SuggestOrgsResult{ + Suggestions: suggestions, + } +} diff --git a/cmd/service/providers.go b/cmd/service/providers.go index 808bb55..32a4a10 100644 --- a/cmd/service/providers.go +++ b/cmd/service/providers.go @@ -166,6 +166,7 @@ func OrganizationSearcherImpl(ctx context.Context) port.OrganizationSearcher { // Parse Clearbit environment variables clearbitAPIKey := os.Getenv("CLEARBIT_CREDENTIAL") clearbitBaseURL := os.Getenv("CLEARBIT_BASE_URL") + clearbitAutocompleteBaseURL := os.Getenv("CLEARBIT_AUTOCOMPLETE_BASE_URL") clearbitTimeout := os.Getenv("CLEARBIT_TIMEOUT") clearbitMaxRetries := os.Getenv("CLEARBIT_MAX_RETRIES") @@ -181,6 +182,7 @@ func OrganizationSearcherImpl(ctx context.Context) port.OrganizationSearcher { clearbitConfig, err := clearbit.NewConfig(clearbitAPIKey, clearbitBaseURL, + clearbitAutocompleteBaseURL, clearbitTimeout, clearbitMaxRetriesInt, clearbitRetryDelay, @@ -191,6 +193,7 @@ func OrganizationSearcherImpl(ctx context.Context) port.OrganizationSearcher { slog.InfoContext(ctx, "initializing Clearbit organization searcher", "base_url", clearbitConfig.BaseURL, + "autocomplete_base_url", clearbitConfig.AutocompleteBaseURL, "timeout", clearbitConfig.Timeout, "max_retries", clearbitConfig.MaxRetries, ) diff --git a/cmd/service/service.go b/cmd/service/service.go index ba86e78..60761f8 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -87,6 +87,27 @@ func (s *querySvcsrvc) QueryOrgs(ctx context.Context, p *querysvc.QueryOrgsPaylo return res, nil } +// Get organization suggestions for typeahead search based on a query. +func (s *querySvcsrvc) SuggestOrgs(ctx context.Context, p *querysvc.SuggestOrgsPayload) (res *querysvc.SuggestOrgsResult, err error) { + + slog.DebugContext(ctx, "querySvc.suggest-orgs", + "query", p.Query, + ) + + // Convert payload to domain criteria + criteria := s.payloadToOrganizationSuggestionCriteria(ctx, p) + + // Execute search using the service layer + result, errSuggestOrgs := s.organizationService.SuggestOrganizations(ctx, criteria) + if errSuggestOrgs != nil { + return nil, wrapError(ctx, errSuggestOrgs) + } + + // Convert domain result to response + res = s.domainOrganizationSuggestionsToResponse(result) + return res, nil +} + // Check if the service is able to take inbound requests. func (s *querySvcsrvc) Readyz(ctx context.Context) (res []byte, err error) { errIsReady := s.resourceService.IsReady(ctx) diff --git a/design/query-svc.go b/design/query-svc.go index 55071a0..c919f8a 100644 --- a/design/query-svc.go +++ b/design/query-svc.go @@ -127,6 +127,44 @@ var _ = dsl.Service("query-svc", func() { }) }) + dsl.Method("suggest-orgs", func() { + dsl.Description("Get organization suggestions for typeahead search based on a query.") + + dsl.Security(JWTAuth) + + dsl.Payload(func() { + dsl.Token("bearer_token", dsl.String, func() { + dsl.Description("Token") + dsl.Example("eyJhbGci...") + }) + dsl.Attribute("version", dsl.String, "Version of the API", func() { + dsl.Enum("1") + dsl.Example("1") + }) + dsl.Attribute("query", dsl.String, "Search query for organization suggestions", func() { + dsl.Example("linux") + dsl.MinLength(1) + }) + dsl.Required("bearer_token", "version", "query") + }) + + dsl.Result(func() { + dsl.Attribute("suggestions", dsl.ArrayOf(OrganizationSuggestion), "Organization suggestions", func() {}) + dsl.Required("suggestions") + }) + + dsl.HTTP(func() { + dsl.GET("/query/orgs/suggest") + dsl.Param("version:v") + dsl.Param("query") + dsl.Header("bearer_token:Authorization") + dsl.Response(dsl.StatusOK) + dsl.Response("BadRequest", dsl.StatusBadRequest) + dsl.Response("InternalServerError", dsl.StatusInternalServerError) + dsl.Response("ServiceUnavailable", dsl.StatusServiceUnavailable) + }) + }) + dsl.Method("readyz", func() { dsl.Description("Check if the service is able to take inbound requests.") dsl.Meta("swagger:generate", "false") diff --git a/design/types.go b/design/types.go index a0b3fe6..dc73ceb 100644 --- a/design/types.go +++ b/design/types.go @@ -100,6 +100,21 @@ var Organization = dsl.Type("Organization", func() { }) }) +var OrganizationSuggestion = dsl.Type("OrganizationSuggestion", func() { + dsl.Description("An organization suggestion for the search.") + + dsl.Attribute("name", dsl.String, "Organization name", func() { + dsl.Example("Linux Foundation") + }) + dsl.Attribute("domain", dsl.String, "Organization domain", func() { + dsl.Example("linuxfoundation.org") + }) + dsl.Attribute("logo", dsl.String, "Organization logo URL", func() { + dsl.Example("https://example.com/logo.png") + }) + dsl.Required("name", "domain") +}) + // Define an example cached LFX resource for the nested "data" attribute for // resource searches. This example happens to be a committee to match the // example value of "committee" for the "type" attribute of Resource. diff --git a/gen/http/cli/lfx_v2_query_service/cli.go b/gen/http/cli/lfx_v2_query_service/cli.go index 978e119..f5fe1aa 100644 --- a/gen/http/cli/lfx_v2_query_service/cli.go +++ b/gen/http/cli/lfx_v2_query_service/cli.go @@ -22,7 +22,7 @@ import ( // // command (subcommand1|subcommand2|...) func UsageCommands() string { - return `query-svc (query-resources|query-orgs|readyz|livez) + return `query-svc (query-resources|query-orgs|suggest-orgs|readyz|livez) ` } @@ -62,6 +62,11 @@ func ParseEndpoint( querySvcQueryOrgsDomainFlag = querySvcQueryOrgsFlags.String("domain", "", "") querySvcQueryOrgsBearerTokenFlag = querySvcQueryOrgsFlags.String("bearer-token", "REQUIRED", "") + querySvcSuggestOrgsFlags = flag.NewFlagSet("suggest-orgs", flag.ExitOnError) + querySvcSuggestOrgsVersionFlag = querySvcSuggestOrgsFlags.String("version", "REQUIRED", "") + querySvcSuggestOrgsQueryFlag = querySvcSuggestOrgsFlags.String("query", "REQUIRED", "") + querySvcSuggestOrgsBearerTokenFlag = querySvcSuggestOrgsFlags.String("bearer-token", "REQUIRED", "") + querySvcReadyzFlags = flag.NewFlagSet("readyz", flag.ExitOnError) querySvcLivezFlags = flag.NewFlagSet("livez", flag.ExitOnError) @@ -69,6 +74,7 @@ func ParseEndpoint( querySvcFlags.Usage = querySvcUsage querySvcQueryResourcesFlags.Usage = querySvcQueryResourcesUsage querySvcQueryOrgsFlags.Usage = querySvcQueryOrgsUsage + querySvcSuggestOrgsFlags.Usage = querySvcSuggestOrgsUsage querySvcReadyzFlags.Usage = querySvcReadyzUsage querySvcLivezFlags.Usage = querySvcLivezUsage @@ -112,6 +118,9 @@ func ParseEndpoint( case "query-orgs": epf = querySvcQueryOrgsFlags + case "suggest-orgs": + epf = querySvcSuggestOrgsFlags + case "readyz": epf = querySvcReadyzFlags @@ -149,6 +158,9 @@ func ParseEndpoint( case "query-orgs": endpoint = c.QueryOrgs() data, err = querysvcc.BuildQueryOrgsPayload(*querySvcQueryOrgsVersionFlag, *querySvcQueryOrgsNameFlag, *querySvcQueryOrgsDomainFlag, *querySvcQueryOrgsBearerTokenFlag) + case "suggest-orgs": + endpoint = c.SuggestOrgs() + data, err = querysvcc.BuildSuggestOrgsPayload(*querySvcSuggestOrgsVersionFlag, *querySvcSuggestOrgsQueryFlag, *querySvcSuggestOrgsBearerTokenFlag) case "readyz": endpoint = c.Readyz() case "livez": @@ -173,6 +185,7 @@ Usage: COMMAND: query-resources: Locate resources by their type or parent, or use typeahead search to query resources by a display name or similar alias. query-orgs: Locate a single organization by name or domain. + suggest-orgs: Get organization suggestions for typeahead search based on a query. readyz: Check if the service is able to take inbound requests. livez: Check if the service is alive. @@ -214,6 +227,19 @@ Example: `, os.Args[0]) } +func querySvcSuggestOrgsUsage() { + fmt.Fprintf(os.Stderr, `%[1]s [flags] query-svc suggest-orgs -version STRING -query STRING -bearer-token STRING + +Get organization suggestions for typeahead search based on a query. + -version STRING: + -query STRING: + -bearer-token STRING: + +Example: + %[1]s query-svc suggest-orgs --version "1" --query "linux" --bearer-token "eyJhbGci..." +`, os.Args[0]) +} + func querySvcReadyzUsage() { fmt.Fprintf(os.Stderr, `%[1]s [flags] query-svc readyz diff --git a/gen/http/openapi.json b/gen/http/openapi.json index 3c306e3..636ff60 100644 --- a/gen/http/openapi.json +++ b/gen/http/openapi.json @@ -1 +1 @@ -{"swagger":"2.0","info":{"title":"LFX V2 - Query Service","description":"Query indexed resources","version":"0.0.1"},"host":"localhost:80","consumes":["application/json","application/xml","application/gob"],"produces":["application/json","application/xml","application/gob"],"paths":{"/query/orgs":{"get":{"tags":["query-svc"],"summary":"query-orgs query-svc","description":"Locate a single organization by name or domain.","operationId":"query-svc#query-orgs","parameters":[{"name":"v","in":"query","description":"Version of the API","required":true,"type":"string","enum":["1"]},{"name":"name","in":"query","description":"Organization name","required":false,"type":"string","minLength":1},{"name":"domain","in":"query","description":"Organization domain or website URL","required":false,"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9-_.]*[a-zA-Z0-9]*\\.[a-zA-Z]{2,}$"},{"name":"Authorization","in":"header","description":"Token","required":true,"type":"string"}],"responses":{"200":{"description":"OK response.","schema":{"$ref":"#/definitions/Organization"}},"400":{"description":"Bad Request response.","schema":{"$ref":"#/definitions/BadRequestError","required":["message"]}},"404":{"description":"Not Found response.","schema":{"$ref":"#/definitions/NotFoundError","required":["message"]}},"500":{"description":"Internal Server Error response.","schema":{"$ref":"#/definitions/InternalServerError","required":["message"]}},"503":{"description":"Service Unavailable response.","schema":{"$ref":"#/definitions/ServiceUnavailableError","required":["message"]}}},"schemes":["http"],"security":[{"jwt_header_Authorization":[]}]}},"/query/resources":{"get":{"tags":["query-svc"],"summary":"query-resources query-svc","description":"Locate resources by their type or parent, or use typeahead search to query resources by a display name or similar alias.","operationId":"query-svc#query-resources","parameters":[{"name":"v","in":"query","description":"Version of the API","required":true,"type":"string","enum":["1"]},{"name":"name","in":"query","description":"Resource name or alias; supports typeahead","required":false,"type":"string","minLength":1},{"name":"parent","in":"query","description":"Parent (for navigation; varies by object type)","required":false,"type":"string","pattern":"^[a-zA-Z]+:[a-zA-Z0-9_-]+$"},{"name":"type","in":"query","description":"Resource type to search","required":false,"type":"string"},{"name":"tags","in":"query","description":"Tags to search (varies by object type)","required":false,"type":"array","items":{"type":"string"},"collectionFormat":"multi"},{"name":"sort","in":"query","description":"Sort order for results","required":false,"type":"string","default":"name_asc","enum":["name_asc","name_desc","updated_asc","updated_desc"]},{"name":"page_token","in":"query","description":"Opaque token for pagination","required":false,"type":"string"},{"name":"Authorization","in":"header","description":"Token","required":true,"type":"string"}],"responses":{"200":{"description":"OK response.","schema":{"$ref":"#/definitions/QuerySvcQueryResourcesResponseBody","required":["resources"]},"headers":{"Cache-Control":{"description":"Cache control header","type":"string"}}},"400":{"description":"Bad Request response.","schema":{"$ref":"#/definitions/BadRequestError","required":["message"]}},"500":{"description":"Internal Server Error response.","schema":{"$ref":"#/definitions/InternalServerError","required":["message"]}},"503":{"description":"Service Unavailable response.","schema":{"$ref":"#/definitions/ServiceUnavailableError","required":["message"]}}},"schemes":["http"],"security":[{"jwt_header_Authorization":[]}]}}},"definitions":{"BadRequestError":{"title":"BadRequestError","type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The request was invalid."}},"description":"Bad request","example":{"message":"The request was invalid."},"required":["message"]},"InternalServerError":{"title":"InternalServerError","type":"object","properties":{"message":{"type":"string","description":"Error message","example":"An internal server error occurred."}},"description":"Internal server error","example":{"message":"An internal server error occurred."},"required":["message"]},"NotFoundError":{"title":"NotFoundError","type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The requested resource was not found."}},"description":"Not found","example":{"message":"The requested resource was not found."},"required":["message"]},"Organization":{"title":"Organization","type":"object","properties":{"domain":{"type":"string","description":"Organization domain","example":"linuxfoundation.org"},"employees":{"type":"string","description":"Employee count or range","example":"100-499"},"industry":{"type":"string","description":"Organization industry classification","example":"Non-Profit"},"name":{"type":"string","description":"Organization name","example":"Linux Foundation"},"sector":{"type":"string","description":"Business sector classification","example":"Technology"}},"example":{"domain":"linuxfoundation.org","employees":"100-499","industry":"Non-Profit","name":"Linux Foundation","sector":"Technology"}},"QuerySvcQueryResourcesResponseBody":{"title":"QuerySvcQueryResourcesResponseBody","type":"object","properties":{"page_token":{"type":"string","description":"Opaque token if more results are available","example":"****"},"resources":{"type":"array","items":{"$ref":"#/definitions/Resource"},"description":"Resources found","example":[{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}]}},"example":{"page_token":"****","resources":[{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}]},"required":["resources"]},"Resource":{"title":"Resource","type":"object","properties":{"data":{"description":"Resource data snapshot","example":{"id":"123","name":"My committee","description":"a committee"}},"id":{"type":"string","description":"Resource ID (within its resource collection)","example":"123"},"type":{"type":"string","description":"Resource type","example":"committee"}},"description":"A resource is a universal representation of an LFX API resource for indexing.","example":{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}},"ServiceUnavailableError":{"title":"ServiceUnavailableError","type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The service is unavailable."}},"description":"Service unavailable","example":{"message":"The service is unavailable."},"required":["message"]}},"securityDefinitions":{"jwt_header_Authorization":{"type":"apiKey","description":"Heimdall authorization","name":"Authorization","in":"header"}}} \ No newline at end of file +{"swagger":"2.0","info":{"title":"LFX V2 - Query Service","description":"Query indexed resources","version":"0.0.1"},"host":"localhost:80","consumes":["application/json","application/xml","application/gob"],"produces":["application/json","application/xml","application/gob"],"paths":{"/query/orgs":{"get":{"tags":["query-svc"],"summary":"query-orgs query-svc","description":"Locate a single organization by name or domain.","operationId":"query-svc#query-orgs","parameters":[{"name":"v","in":"query","description":"Version of the API","required":true,"type":"string","enum":["1"]},{"name":"name","in":"query","description":"Organization name","required":false,"type":"string","minLength":1},{"name":"domain","in":"query","description":"Organization domain or website URL","required":false,"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9-_.]*[a-zA-Z0-9]*\\.[a-zA-Z]{2,}$"},{"name":"Authorization","in":"header","description":"Token","required":true,"type":"string"}],"responses":{"200":{"description":"OK response.","schema":{"$ref":"#/definitions/Organization"}},"400":{"description":"Bad Request response.","schema":{"$ref":"#/definitions/BadRequestError","required":["message"]}},"404":{"description":"Not Found response.","schema":{"$ref":"#/definitions/NotFoundError","required":["message"]}},"500":{"description":"Internal Server Error response.","schema":{"$ref":"#/definitions/InternalServerError","required":["message"]}},"503":{"description":"Service Unavailable response.","schema":{"$ref":"#/definitions/ServiceUnavailableError","required":["message"]}}},"schemes":["http"],"security":[{"jwt_header_Authorization":[]}]}},"/query/orgs/suggest":{"get":{"tags":["query-svc"],"summary":"suggest-orgs query-svc","description":"Get organization suggestions for typeahead search based on a query.","operationId":"query-svc#suggest-orgs","parameters":[{"name":"v","in":"query","description":"Version of the API","required":true,"type":"string","enum":["1"]},{"name":"query","in":"query","description":"Search query for organization suggestions","required":true,"type":"string","minLength":1},{"name":"Authorization","in":"header","description":"Token","required":true,"type":"string"}],"responses":{"200":{"description":"OK response.","schema":{"$ref":"#/definitions/QuerySvcSuggestOrgsResponseBody","required":["suggestions"]}},"400":{"description":"Bad Request response.","schema":{"$ref":"#/definitions/BadRequestError","required":["message"]}},"500":{"description":"Internal Server Error response.","schema":{"$ref":"#/definitions/InternalServerError","required":["message"]}},"503":{"description":"Service Unavailable response.","schema":{"$ref":"#/definitions/ServiceUnavailableError","required":["message"]}}},"schemes":["http"],"security":[{"jwt_header_Authorization":[]}]}},"/query/resources":{"get":{"tags":["query-svc"],"summary":"query-resources query-svc","description":"Locate resources by their type or parent, or use typeahead search to query resources by a display name or similar alias.","operationId":"query-svc#query-resources","parameters":[{"name":"v","in":"query","description":"Version of the API","required":true,"type":"string","enum":["1"]},{"name":"name","in":"query","description":"Resource name or alias; supports typeahead","required":false,"type":"string","minLength":1},{"name":"parent","in":"query","description":"Parent (for navigation; varies by object type)","required":false,"type":"string","pattern":"^[a-zA-Z]+:[a-zA-Z0-9_-]+$"},{"name":"type","in":"query","description":"Resource type to search","required":false,"type":"string"},{"name":"tags","in":"query","description":"Tags to search (varies by object type)","required":false,"type":"array","items":{"type":"string"},"collectionFormat":"multi"},{"name":"sort","in":"query","description":"Sort order for results","required":false,"type":"string","default":"name_asc","enum":["name_asc","name_desc","updated_asc","updated_desc"]},{"name":"page_token","in":"query","description":"Opaque token for pagination","required":false,"type":"string"},{"name":"Authorization","in":"header","description":"Token","required":true,"type":"string"}],"responses":{"200":{"description":"OK response.","schema":{"$ref":"#/definitions/QuerySvcQueryResourcesResponseBody","required":["resources"]},"headers":{"Cache-Control":{"description":"Cache control header","type":"string"}}},"400":{"description":"Bad Request response.","schema":{"$ref":"#/definitions/BadRequestError","required":["message"]}},"500":{"description":"Internal Server Error response.","schema":{"$ref":"#/definitions/InternalServerError","required":["message"]}},"503":{"description":"Service Unavailable response.","schema":{"$ref":"#/definitions/ServiceUnavailableError","required":["message"]}}},"schemes":["http"],"security":[{"jwt_header_Authorization":[]}]}}},"definitions":{"BadRequestError":{"title":"BadRequestError","type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The request was invalid."}},"description":"Bad request","example":{"message":"The request was invalid."},"required":["message"]},"InternalServerError":{"title":"InternalServerError","type":"object","properties":{"message":{"type":"string","description":"Error message","example":"An internal server error occurred."}},"description":"Internal server error","example":{"message":"An internal server error occurred."},"required":["message"]},"NotFoundError":{"title":"NotFoundError","type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The requested resource was not found."}},"description":"Not found","example":{"message":"The requested resource was not found."},"required":["message"]},"Organization":{"title":"Organization","type":"object","properties":{"domain":{"type":"string","description":"Organization domain","example":"linuxfoundation.org"},"employees":{"type":"string","description":"Employee count or range","example":"100-499"},"industry":{"type":"string","description":"Organization industry classification","example":"Non-Profit"},"name":{"type":"string","description":"Organization name","example":"Linux Foundation"},"sector":{"type":"string","description":"Business sector classification","example":"Technology"}},"example":{"domain":"linuxfoundation.org","employees":"100-499","industry":"Non-Profit","name":"Linux Foundation","sector":"Technology"}},"OrganizationSuggestion":{"title":"OrganizationSuggestion","type":"object","properties":{"domain":{"type":"string","description":"Organization domain","example":"linuxfoundation.org"},"logo":{"type":"string","description":"Organization logo URL","example":"https://example.com/logo.png"},"name":{"type":"string","description":"Organization name","example":"Linux Foundation"}},"description":"An organization suggestion for typeahead search.","example":{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},"required":["name","domain"]},"QuerySvcQueryResourcesResponseBody":{"title":"QuerySvcQueryResourcesResponseBody","type":"object","properties":{"page_token":{"type":"string","description":"Opaque token if more results are available","example":"****"},"resources":{"type":"array","items":{"$ref":"#/definitions/Resource"},"description":"Resources found","example":[{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}]}},"example":{"page_token":"****","resources":[{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}]},"required":["resources"]},"QuerySvcSuggestOrgsResponseBody":{"title":"QuerySvcSuggestOrgsResponseBody","type":"object","properties":{"suggestions":{"type":"array","items":{"$ref":"#/definitions/OrganizationSuggestion"},"description":"Organization suggestions","example":[{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"}]}},"example":{"suggestions":[{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"}]},"required":["suggestions"]},"Resource":{"title":"Resource","type":"object","properties":{"data":{"description":"Resource data snapshot","example":{"id":"123","name":"My committee","description":"a committee"}},"id":{"type":"string","description":"Resource ID (within its resource collection)","example":"123"},"type":{"type":"string","description":"Resource type","example":"committee"}},"description":"A resource is a universal representation of an LFX API resource for indexing.","example":{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}},"ServiceUnavailableError":{"title":"ServiceUnavailableError","type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The service is unavailable."}},"description":"Service unavailable","example":{"message":"The service is unavailable."},"required":["message"]}},"securityDefinitions":{"jwt_header_Authorization":{"type":"apiKey","description":"Heimdall authorization","name":"Authorization","in":"header"}}} \ No newline at end of file diff --git a/gen/http/openapi.yaml b/gen/http/openapi.yaml index 84eeca0..0d71266 100644 --- a/gen/http/openapi.yaml +++ b/gen/http/openapi.yaml @@ -78,6 +78,61 @@ paths: - http security: - jwt_header_Authorization: [] + /query/orgs/suggest: + get: + tags: + - query-svc + summary: suggest-orgs query-svc + description: Get organization suggestions for typeahead search based on a query. + operationId: query-svc#suggest-orgs + parameters: + - name: v + in: query + description: Version of the API + required: true + type: string + enum: + - "1" + - name: query + in: query + description: Search query for organization suggestions + required: true + type: string + minLength: 1 + - name: Authorization + in: header + description: Token + required: true + type: string + responses: + "200": + description: OK response. + schema: + $ref: '#/definitions/QuerySvcSuggestOrgsResponseBody' + required: + - suggestions + "400": + description: Bad Request response. + schema: + $ref: '#/definitions/BadRequestError' + required: + - message + "500": + description: Internal Server Error response. + schema: + $ref: '#/definitions/InternalServerError' + required: + - message + "503": + description: Service Unavailable response. + schema: + $ref: '#/definitions/ServiceUnavailableError' + required: + - message + schemes: + - http + security: + - jwt_header_Authorization: [] /query/resources: get: tags: @@ -242,6 +297,30 @@ definitions: industry: Non-Profit name: Linux Foundation sector: Technology + OrganizationSuggestion: + title: OrganizationSuggestion + type: object + properties: + domain: + type: string + description: Organization domain + example: linuxfoundation.org + logo: + type: string + description: Organization logo URL + example: https://example.com/logo.png + name: + type: string + description: Organization name + example: Linux Foundation + description: An organization suggestion for typeahead search. + example: + domain: linuxfoundation.org + logo: https://example.com/logo.png + name: Linux Foundation + required: + - name + - domain QuerySvcQueryResourcesResponseBody: title: QuerySvcQueryResourcesResponseBody type: object @@ -268,6 +347,18 @@ definitions: description: a committee id: "123" type: committee + - data: + id: "123" + name: My committee + description: a committee + id: "123" + type: committee + - data: + id: "123" + name: My committee + description: a committee + id: "123" + type: committee example: page_token: '****' resources: @@ -297,6 +388,41 @@ definitions: type: committee required: - resources + QuerySvcSuggestOrgsResponseBody: + title: QuerySvcSuggestOrgsResponseBody + type: object + properties: + suggestions: + type: array + items: + $ref: '#/definitions/OrganizationSuggestion' + description: Organization suggestions + example: + - domain: linuxfoundation.org + logo: https://example.com/logo.png + name: Linux Foundation + - domain: linuxfoundation.org + logo: https://example.com/logo.png + name: Linux Foundation + - domain: linuxfoundation.org + logo: https://example.com/logo.png + name: Linux Foundation + example: + suggestions: + - domain: linuxfoundation.org + logo: https://example.com/logo.png + name: Linux Foundation + - domain: linuxfoundation.org + logo: https://example.com/logo.png + name: Linux Foundation + - domain: linuxfoundation.org + logo: https://example.com/logo.png + name: Linux Foundation + - domain: linuxfoundation.org + logo: https://example.com/logo.png + name: Linux Foundation + required: + - suggestions Resource: title: Resource type: object diff --git a/gen/http/openapi3.json b/gen/http/openapi3.json index 7c1dcd0..5e78ff1 100644 --- a/gen/http/openapi3.json +++ b/gen/http/openapi3.json @@ -1 +1 @@ -{"openapi":"3.0.3","info":{"title":"LFX V2 - Query Service","description":"Query indexed resources","version":"0.0.1"},"servers":[{"url":"http://localhost:80","description":"Default server for lfx-v2-query-service"}],"paths":{"/query/orgs":{"get":{"tags":["query-svc"],"summary":"query-orgs query-svc","description":"Locate a single organization by name or domain.","operationId":"query-svc#query-orgs","parameters":[{"name":"v","in":"query","description":"Version of the API","allowEmptyValue":true,"required":true,"schema":{"type":"string","description":"Version of the API","example":"1","enum":["1"]},"example":"1"},{"name":"name","in":"query","description":"Organization name","allowEmptyValue":true,"schema":{"type":"string","description":"Organization name","example":"The Linux Foundation","minLength":1},"example":"The Linux Foundation"},{"name":"domain","in":"query","description":"Organization domain or website URL","allowEmptyValue":true,"schema":{"type":"string","description":"Organization domain or website URL","example":"linuxfoundation.org","pattern":"^[a-zA-Z0-9][a-zA-Z0-9-_.]*[a-zA-Z0-9]*\\.[a-zA-Z]{2,}$"},"example":"linuxfoundation.org"}],"responses":{"200":{"description":"OK response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"},"example":{"domain":"linuxfoundation.org","employees":"100-499","industry":"Non-Profit","name":"Linux Foundation","sector":"Technology"}}}},"400":{"description":"BadRequest: Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BadRequestError"},"example":{"message":"The request was invalid."}}}},"404":{"description":"NotFound: Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotFoundError"},"example":{"message":"The requested resource was not found."}}}},"500":{"description":"InternalServerError: Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InternalServerError"},"example":{"message":"An internal server error occurred."}}}},"503":{"description":"ServiceUnavailable: Service unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableError"},"example":{"message":"The service is unavailable."}}}}},"security":[{"jwt_header_Authorization":[]}]}},"/query/resources":{"get":{"tags":["query-svc"],"summary":"query-resources query-svc","description":"Locate resources by their type or parent, or use typeahead search to query resources by a display name or similar alias.","operationId":"query-svc#query-resources","parameters":[{"name":"v","in":"query","description":"Version of the API","allowEmptyValue":true,"required":true,"schema":{"type":"string","description":"Version of the API","example":"1","enum":["1"]},"example":"1"},{"name":"name","in":"query","description":"Resource name or alias; supports typeahead","allowEmptyValue":true,"schema":{"type":"string","description":"Resource name or alias; supports typeahead","example":"gov board","minLength":1},"example":"gov board"},{"name":"parent","in":"query","description":"Parent (for navigation; varies by object type)","allowEmptyValue":true,"schema":{"type":"string","description":"Parent (for navigation; varies by object type)","example":"project:123","pattern":"^[a-zA-Z]+:[a-zA-Z0-9_-]+$"},"example":"project:123"},{"name":"type","in":"query","description":"Resource type to search","allowEmptyValue":true,"schema":{"type":"string","description":"Resource type to search","example":"committee"},"example":"committee"},{"name":"tags","in":"query","description":"Tags to search (varies by object type)","allowEmptyValue":true,"schema":{"type":"array","items":{"type":"string","example":"Tempore consequatur est architecto harum in."},"description":"Tags to search (varies by object type)","example":["active"]},"example":["active"]},{"name":"sort","in":"query","description":"Sort order for results","allowEmptyValue":true,"schema":{"type":"string","description":"Sort order for results","default":"name_asc","example":"updated_desc","enum":["name_asc","name_desc","updated_asc","updated_desc"]},"example":"updated_desc"},{"name":"page_token","in":"query","description":"Opaque token for pagination","allowEmptyValue":true,"schema":{"type":"string","description":"Opaque token for pagination","example":"****"},"example":"****"}],"responses":{"200":{"description":"OK response.","headers":{"Cache-Control":{"description":"Cache control header","schema":{"type":"string","description":"Cache control header","example":"public, max-age=300"},"example":"public, max-age=300"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryResourcesResponseBody"},"example":{"page_token":"****","resources":[{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}]}}}},"400":{"description":"BadRequest: Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BadRequestError"},"example":{"message":"The request was invalid."}}}},"500":{"description":"InternalServerError: Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InternalServerError"},"example":{"message":"An internal server error occurred."}}}},"503":{"description":"ServiceUnavailable: Service unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableError"},"example":{"message":"The service is unavailable."}}}}},"security":[{"jwt_header_Authorization":[]}]}}},"components":{"schemas":{"BadRequestError":{"type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The request was invalid."}},"example":{"message":"The request was invalid."},"required":["message"]},"InternalServerError":{"type":"object","properties":{"message":{"type":"string","description":"Error message","example":"An internal server error occurred."}},"example":{"message":"An internal server error occurred."},"required":["message"]},"NotFoundError":{"type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The requested resource was not found."}},"example":{"message":"The requested resource was not found."},"required":["message"]},"Organization":{"type":"object","properties":{"domain":{"type":"string","description":"Organization domain","example":"linuxfoundation.org"},"employees":{"type":"string","description":"Employee count or range","example":"100-499"},"industry":{"type":"string","description":"Organization industry classification","example":"Non-Profit"},"name":{"type":"string","description":"Organization name","example":"Linux Foundation"},"sector":{"type":"string","description":"Business sector classification","example":"Technology"}},"description":"An organization is a universal representation of an LFX API organization.","example":{"domain":"linuxfoundation.org","employees":"100-499","industry":"Non-Profit","name":"Linux Foundation","sector":"Technology"}},"QueryResourcesResponseBody":{"type":"object","properties":{"page_token":{"type":"string","description":"Opaque token if more results are available","example":"****"},"resources":{"type":"array","items":{"$ref":"#/components/schemas/Resource"},"description":"Resources found","example":[{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}]}},"example":{"page_token":"****","resources":[{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}]},"required":["resources"]},"Resource":{"type":"object","properties":{"data":{"description":"Resource data snapshot","example":{"id":"123","name":"My committee","description":"a committee"}},"id":{"type":"string","description":"Resource ID (within its resource collection)","example":"123"},"type":{"type":"string","description":"Resource type","example":"committee"}},"description":"A resource is a universal representation of an LFX API resource for indexing.","example":{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}},"ServiceUnavailableError":{"type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The service is unavailable."}},"example":{"message":"The service is unavailable."},"required":["message"]},"Sortable":{"type":"object","properties":{"page_token":{"type":"string","description":"Opaque token for pagination","example":"****"},"sort":{"type":"string","description":"Sort order for results","default":"name_asc","example":"updated_desc","enum":["name_asc","name_desc","updated_asc","updated_desc"]}},"example":{"page_token":"****","sort":"updated_desc"}}},"securitySchemes":{"jwt_header_Authorization":{"type":"http","description":"Heimdall authorization","scheme":"bearer"}}},"tags":[{"name":"query-svc","description":"The query service provides resource and user queries."}]} \ No newline at end of file +{"openapi":"3.0.3","info":{"title":"LFX V2 - Query Service","description":"Query indexed resources","version":"0.0.1"},"servers":[{"url":"http://localhost:80","description":"Default server for lfx-v2-query-service"}],"paths":{"/query/orgs":{"get":{"tags":["query-svc"],"summary":"query-orgs query-svc","description":"Locate a single organization by name or domain.","operationId":"query-svc#query-orgs","parameters":[{"name":"v","in":"query","description":"Version of the API","allowEmptyValue":true,"required":true,"schema":{"type":"string","description":"Version of the API","example":"1","enum":["1"]},"example":"1"},{"name":"name","in":"query","description":"Organization name","allowEmptyValue":true,"schema":{"type":"string","description":"Organization name","example":"The Linux Foundation","minLength":1},"example":"The Linux Foundation"},{"name":"domain","in":"query","description":"Organization domain or website URL","allowEmptyValue":true,"schema":{"type":"string","description":"Organization domain or website URL","example":"linuxfoundation.org","pattern":"^[a-zA-Z0-9][a-zA-Z0-9-_.]*[a-zA-Z0-9]*\\.[a-zA-Z]{2,}$"},"example":"linuxfoundation.org"}],"responses":{"200":{"description":"OK response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"},"example":{"domain":"linuxfoundation.org","employees":"100-499","industry":"Non-Profit","name":"Linux Foundation","sector":"Technology"}}}},"400":{"description":"BadRequest: Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BadRequestError"},"example":{"message":"The request was invalid."}}}},"404":{"description":"NotFound: Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotFoundError"},"example":{"message":"The requested resource was not found."}}}},"500":{"description":"InternalServerError: Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InternalServerError"},"example":{"message":"An internal server error occurred."}}}},"503":{"description":"ServiceUnavailable: Service unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableError"},"example":{"message":"The service is unavailable."}}}}},"security":[{"jwt_header_Authorization":[]}]}},"/query/orgs/suggest":{"get":{"tags":["query-svc"],"summary":"suggest-orgs query-svc","description":"Get organization suggestions for typeahead search based on a query.","operationId":"query-svc#suggest-orgs","parameters":[{"name":"v","in":"query","description":"Version of the API","allowEmptyValue":true,"required":true,"schema":{"type":"string","description":"Version of the API","example":"1","enum":["1"]},"example":"1"},{"name":"query","in":"query","description":"Search query for organization suggestions","allowEmptyValue":true,"required":true,"schema":{"type":"string","description":"Search query for organization suggestions","example":"linux","minLength":1},"example":"linux"}],"responses":{"200":{"description":"OK response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuggestOrgsResponseBody"},"example":{"suggestions":[{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"}]}}}},"400":{"description":"BadRequest: Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BadRequestError"},"example":{"message":"The request was invalid."}}}},"500":{"description":"InternalServerError: Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InternalServerError"},"example":{"message":"An internal server error occurred."}}}},"503":{"description":"ServiceUnavailable: Service unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableError"},"example":{"message":"The service is unavailable."}}}}},"security":[{"jwt_header_Authorization":[]}]}},"/query/resources":{"get":{"tags":["query-svc"],"summary":"query-resources query-svc","description":"Locate resources by their type or parent, or use typeahead search to query resources by a display name or similar alias.","operationId":"query-svc#query-resources","parameters":[{"name":"v","in":"query","description":"Version of the API","allowEmptyValue":true,"required":true,"schema":{"type":"string","description":"Version of the API","example":"1","enum":["1"]},"example":"1"},{"name":"name","in":"query","description":"Resource name or alias; supports typeahead","allowEmptyValue":true,"schema":{"type":"string","description":"Resource name or alias; supports typeahead","example":"gov board","minLength":1},"example":"gov board"},{"name":"parent","in":"query","description":"Parent (for navigation; varies by object type)","allowEmptyValue":true,"schema":{"type":"string","description":"Parent (for navigation; varies by object type)","example":"project:123","pattern":"^[a-zA-Z]+:[a-zA-Z0-9_-]+$"},"example":"project:123"},{"name":"type","in":"query","description":"Resource type to search","allowEmptyValue":true,"schema":{"type":"string","description":"Resource type to search","example":"committee"},"example":"committee"},{"name":"tags","in":"query","description":"Tags to search (varies by object type)","allowEmptyValue":true,"schema":{"type":"array","items":{"type":"string","example":"Animi aspernatur."},"description":"Tags to search (varies by object type)","example":["active"]},"example":["active"]},{"name":"sort","in":"query","description":"Sort order for results","allowEmptyValue":true,"schema":{"type":"string","description":"Sort order for results","default":"name_asc","example":"updated_desc","enum":["name_asc","name_desc","updated_asc","updated_desc"]},"example":"updated_desc"},{"name":"page_token","in":"query","description":"Opaque token for pagination","allowEmptyValue":true,"schema":{"type":"string","description":"Opaque token for pagination","example":"****"},"example":"****"}],"responses":{"200":{"description":"OK response.","headers":{"Cache-Control":{"description":"Cache control header","schema":{"type":"string","description":"Cache control header","example":"public, max-age=300"},"example":"public, max-age=300"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryResourcesResponseBody"},"example":{"page_token":"****","resources":[{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}]}}}},"400":{"description":"BadRequest: Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BadRequestError"},"example":{"message":"The request was invalid."}}}},"500":{"description":"InternalServerError: Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InternalServerError"},"example":{"message":"An internal server error occurred."}}}},"503":{"description":"ServiceUnavailable: Service unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableError"},"example":{"message":"The service is unavailable."}}}}},"security":[{"jwt_header_Authorization":[]}]}}},"components":{"schemas":{"BadRequestError":{"type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The request was invalid."}},"example":{"message":"The request was invalid."},"required":["message"]},"InternalServerError":{"type":"object","properties":{"message":{"type":"string","description":"Error message","example":"An internal server error occurred."}},"example":{"message":"An internal server error occurred."},"required":["message"]},"NotFoundError":{"type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The requested resource was not found."}},"example":{"message":"The requested resource was not found."},"required":["message"]},"Organization":{"type":"object","properties":{"domain":{"type":"string","description":"Organization domain","example":"linuxfoundation.org"},"employees":{"type":"string","description":"Employee count or range","example":"100-499"},"industry":{"type":"string","description":"Organization industry classification","example":"Non-Profit"},"name":{"type":"string","description":"Organization name","example":"Linux Foundation"},"sector":{"type":"string","description":"Business sector classification","example":"Technology"}},"description":"An organization is a universal representation of an LFX API organization.","example":{"domain":"linuxfoundation.org","employees":"100-499","industry":"Non-Profit","name":"Linux Foundation","sector":"Technology"}},"OrganizationSuggestion":{"type":"object","properties":{"domain":{"type":"string","description":"Organization domain","example":"linuxfoundation.org"},"logo":{"type":"string","description":"Organization logo URL","example":"https://example.com/logo.png"},"name":{"type":"string","description":"Organization name","example":"Linux Foundation"}},"description":"An organization suggestion for typeahead search.","example":{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},"required":["name","domain"]},"QueryResourcesResponseBody":{"type":"object","properties":{"page_token":{"type":"string","description":"Opaque token if more results are available","example":"****"},"resources":{"type":"array","items":{"$ref":"#/components/schemas/Resource"},"description":"Resources found","example":[{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}]}},"example":{"page_token":"****","resources":[{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"},{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}]},"required":["resources"]},"Resource":{"type":"object","properties":{"data":{"description":"Resource data snapshot","example":{"id":"123","name":"My committee","description":"a committee"}},"id":{"type":"string","description":"Resource ID (within its resource collection)","example":"123"},"type":{"type":"string","description":"Resource type","example":"committee"}},"description":"A resource is a universal representation of an LFX API resource for indexing.","example":{"data":{"id":"123","name":"My committee","description":"a committee"},"id":"123","type":"committee"}},"ServiceUnavailableError":{"type":"object","properties":{"message":{"type":"string","description":"Error message","example":"The service is unavailable."}},"example":{"message":"The service is unavailable."},"required":["message"]},"Sortable":{"type":"object","properties":{"page_token":{"type":"string","description":"Opaque token for pagination","example":"****"},"sort":{"type":"string","description":"Sort order for results","default":"name_asc","example":"updated_desc","enum":["name_asc","name_desc","updated_asc","updated_desc"]}},"example":{"page_token":"****","sort":"updated_desc"}},"SuggestOrgsResponseBody":{"type":"object","properties":{"suggestions":{"type":"array","items":{"$ref":"#/components/schemas/OrganizationSuggestion"},"description":"Organization suggestions","example":[{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"}]}},"example":{"suggestions":[{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"},{"domain":"linuxfoundation.org","logo":"https://example.com/logo.png","name":"Linux Foundation"}]},"required":["suggestions"]}},"securitySchemes":{"jwt_header_Authorization":{"type":"http","description":"Heimdall authorization","scheme":"bearer"}}},"tags":[{"name":"query-svc","description":"The query service provides resource and user queries."}]} \ No newline at end of file diff --git a/gen/http/openapi3.yaml b/gen/http/openapi3.yaml index ec1968f..d6109bc 100644 --- a/gen/http/openapi3.yaml +++ b/gen/http/openapi3.yaml @@ -94,6 +94,81 @@ paths: message: The service is unavailable. security: - jwt_header_Authorization: [] + /query/orgs/suggest: + get: + tags: + - query-svc + summary: suggest-orgs query-svc + description: Get organization suggestions for typeahead search based on a query. + operationId: query-svc#suggest-orgs + parameters: + - name: v + in: query + description: Version of the API + allowEmptyValue: true + required: true + schema: + type: string + description: Version of the API + example: "1" + enum: + - "1" + example: "1" + - name: query + in: query + description: Search query for organization suggestions + allowEmptyValue: true + required: true + schema: + type: string + description: Search query for organization suggestions + example: linux + minLength: 1 + example: linux + responses: + "200": + description: OK response. + content: + application/json: + schema: + $ref: '#/components/schemas/SuggestOrgsResponseBody' + example: + suggestions: + - domain: linuxfoundation.org + logo: https://example.com/logo.png + name: Linux Foundation + - domain: linuxfoundation.org + logo: https://example.com/logo.png + name: Linux Foundation + - domain: linuxfoundation.org + logo: https://example.com/logo.png + name: Linux Foundation + "400": + description: 'BadRequest: Bad request' + content: + application/json: + schema: + $ref: '#/components/schemas/BadRequestError' + example: + message: The request was invalid. + "500": + description: 'InternalServerError: Internal server error' + content: + application/json: + schema: + $ref: '#/components/schemas/InternalServerError' + example: + message: An internal server error occurred. + "503": + description: 'ServiceUnavailable: Service unavailable' + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceUnavailableError' + example: + message: The service is unavailable. + security: + - jwt_header_Authorization: [] /query/resources: get: tags: @@ -151,7 +226,7 @@ paths: type: array items: type: string - example: Tempore consequatur est architecto harum in. + example: Animi aspernatur. description: Tags to search (varies by object type) example: - active @@ -308,6 +383,29 @@ components: industry: Non-Profit name: Linux Foundation sector: Technology + OrganizationSuggestion: + type: object + properties: + domain: + type: string + description: Organization domain + example: linuxfoundation.org + logo: + type: string + description: Organization logo URL + example: https://example.com/logo.png + name: + type: string + description: Organization name + example: Linux Foundation + description: An organization suggestion for typeahead search. + example: + domain: linuxfoundation.org + logo: https://example.com/logo.png + name: Linux Foundation + required: + - name + - domain QueryResourcesResponseBody: type: object properties: @@ -430,6 +528,34 @@ components: example: page_token: '****' sort: updated_desc + SuggestOrgsResponseBody: + type: object + properties: + suggestions: + type: array + items: + $ref: '#/components/schemas/OrganizationSuggestion' + description: Organization suggestions + example: + - domain: linuxfoundation.org + logo: https://example.com/logo.png + name: Linux Foundation + - domain: linuxfoundation.org + logo: https://example.com/logo.png + name: Linux Foundation + example: + suggestions: + - domain: linuxfoundation.org + logo: https://example.com/logo.png + name: Linux Foundation + - domain: linuxfoundation.org + logo: https://example.com/logo.png + name: Linux Foundation + - domain: linuxfoundation.org + logo: https://example.com/logo.png + name: Linux Foundation + required: + - suggestions securitySchemes: jwt_header_Authorization: type: http diff --git a/gen/http/query_svc/client/cli.go b/gen/http/query_svc/client/cli.go index 2039924..de78f91 100644 --- a/gen/http/query_svc/client/cli.go +++ b/gen/http/query_svc/client/cli.go @@ -150,3 +150,39 @@ func BuildQueryOrgsPayload(querySvcQueryOrgsVersion string, querySvcQueryOrgsNam return v, nil } + +// BuildSuggestOrgsPayload builds the payload for the query-svc suggest-orgs +// endpoint from CLI flags. +func BuildSuggestOrgsPayload(querySvcSuggestOrgsVersion string, querySvcSuggestOrgsQuery string, querySvcSuggestOrgsBearerToken string) (*querysvc.SuggestOrgsPayload, error) { + var err error + var version string + { + version = querySvcSuggestOrgsVersion + if !(version == "1") { + err = goa.MergeErrors(err, goa.InvalidEnumValueError("version", version, []any{"1"})) + } + if err != nil { + return nil, err + } + } + var query string + { + query = querySvcSuggestOrgsQuery + if utf8.RuneCountInString(query) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("query", query, utf8.RuneCountInString(query), 1, true)) + } + if err != nil { + return nil, err + } + } + var bearerToken string + { + bearerToken = querySvcSuggestOrgsBearerToken + } + v := &querysvc.SuggestOrgsPayload{} + v.Version = version + v.Query = query + v.BearerToken = bearerToken + + return v, nil +} diff --git a/gen/http/query_svc/client/client.go b/gen/http/query_svc/client/client.go index e71384a..bd1e931 100644 --- a/gen/http/query_svc/client/client.go +++ b/gen/http/query_svc/client/client.go @@ -25,6 +25,10 @@ type Client struct { // endpoint. QueryOrgsDoer goahttp.Doer + // SuggestOrgs Doer is the HTTP client used to make requests to the + // suggest-orgs endpoint. + SuggestOrgsDoer goahttp.Doer + // Readyz Doer is the HTTP client used to make requests to the readyz endpoint. ReadyzDoer goahttp.Doer @@ -53,6 +57,7 @@ func NewClient( return &Client{ QueryResourcesDoer: doer, QueryOrgsDoer: doer, + SuggestOrgsDoer: doer, ReadyzDoer: doer, LivezDoer: doer, RestoreResponseBody: restoreBody, @@ -111,6 +116,30 @@ func (c *Client) QueryOrgs() goa.Endpoint { } } +// SuggestOrgs returns an endpoint that makes HTTP requests to the query-svc +// service suggest-orgs server. +func (c *Client) SuggestOrgs() goa.Endpoint { + var ( + encodeRequest = EncodeSuggestOrgsRequest(c.encoder) + decodeResponse = DecodeSuggestOrgsResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildSuggestOrgsRequest(ctx, v) + if err != nil { + return nil, err + } + err = encodeRequest(req, v) + if err != nil { + return nil, err + } + resp, err := c.SuggestOrgsDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("query-svc", "suggest-orgs", err) + } + return decodeResponse(resp) + } +} + // Readyz returns an endpoint that makes HTTP requests to the query-svc service // readyz server. func (c *Client) Readyz() goa.Endpoint { diff --git a/gen/http/query_svc/client/encode_decode.go b/gen/http/query_svc/client/encode_decode.go index 3562aa3..a51dab1 100644 --- a/gen/http/query_svc/client/encode_decode.go +++ b/gen/http/query_svc/client/encode_decode.go @@ -309,6 +309,132 @@ func DecodeQueryOrgsResponse(decoder func(*http.Response) goahttp.Decoder, resto } } +// BuildSuggestOrgsRequest instantiates a HTTP request object with method and +// path set to call the "query-svc" service "suggest-orgs" endpoint +func (c *Client) BuildSuggestOrgsRequest(ctx context.Context, v any) (*http.Request, error) { + u := &url.URL{Scheme: c.scheme, Host: c.host, Path: SuggestOrgsQuerySvcPath()} + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, goahttp.ErrInvalidURL("query-svc", "suggest-orgs", u.String(), err) + } + if ctx != nil { + req = req.WithContext(ctx) + } + + return req, nil +} + +// EncodeSuggestOrgsRequest returns an encoder for requests sent to the +// query-svc suggest-orgs server. +func EncodeSuggestOrgsRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + p, ok := v.(*querysvc.SuggestOrgsPayload) + if !ok { + return goahttp.ErrInvalidType("query-svc", "suggest-orgs", "*querysvc.SuggestOrgsPayload", v) + } + { + head := p.BearerToken + if !strings.Contains(head, " ") { + req.Header.Set("Authorization", "Bearer "+head) + } else { + req.Header.Set("Authorization", head) + } + } + values := req.URL.Query() + values.Add("v", p.Version) + values.Add("query", p.Query) + req.URL.RawQuery = values.Encode() + return nil + } +} + +// DecodeSuggestOrgsResponse returns a decoder for responses returned by the +// query-svc suggest-orgs endpoint. restoreBody controls whether the response +// body should be restored after having been read. +// DecodeSuggestOrgsResponse may return the following errors: +// - "BadRequest" (type *querysvc.BadRequestError): http.StatusBadRequest +// - "InternalServerError" (type *querysvc.InternalServerError): http.StatusInternalServerError +// - "ServiceUnavailable" (type *querysvc.ServiceUnavailableError): http.StatusServiceUnavailable +// - error: internal error +func DecodeSuggestOrgsResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (any, error) { + if restoreBody { + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer resp.Body.Close() + } + switch resp.StatusCode { + case http.StatusOK: + var ( + body SuggestOrgsResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("query-svc", "suggest-orgs", err) + } + err = ValidateSuggestOrgsResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("query-svc", "suggest-orgs", err) + } + res := NewSuggestOrgsResultOK(&body) + return res, nil + case http.StatusBadRequest: + var ( + body SuggestOrgsBadRequestResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("query-svc", "suggest-orgs", err) + } + err = ValidateSuggestOrgsBadRequestResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("query-svc", "suggest-orgs", err) + } + return nil, NewSuggestOrgsBadRequest(&body) + case http.StatusInternalServerError: + var ( + body SuggestOrgsInternalServerErrorResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("query-svc", "suggest-orgs", err) + } + err = ValidateSuggestOrgsInternalServerErrorResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("query-svc", "suggest-orgs", err) + } + return nil, NewSuggestOrgsInternalServerError(&body) + case http.StatusServiceUnavailable: + var ( + body SuggestOrgsServiceUnavailableResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("query-svc", "suggest-orgs", err) + } + err = ValidateSuggestOrgsServiceUnavailableResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("query-svc", "suggest-orgs", err) + } + return nil, NewSuggestOrgsServiceUnavailable(&body) + default: + body, _ := io.ReadAll(resp.Body) + return nil, goahttp.ErrInvalidResponse("query-svc", "suggest-orgs", resp.StatusCode, string(body)) + } + } +} + // BuildReadyzRequest instantiates a HTTP request object with method and path // set to call the "query-svc" service "readyz" endpoint func (c *Client) BuildReadyzRequest(ctx context.Context, v any) (*http.Request, error) { @@ -437,3 +563,16 @@ func unmarshalResourceResponseBodyToQuerysvcResource(v *ResourceResponseBody) *q return res } + +// unmarshalOrganizationSuggestionResponseBodyToQuerysvcOrganizationSuggestion +// builds a value of type *querysvc.OrganizationSuggestion from a value of type +// *OrganizationSuggestionResponseBody. +func unmarshalOrganizationSuggestionResponseBodyToQuerysvcOrganizationSuggestion(v *OrganizationSuggestionResponseBody) *querysvc.OrganizationSuggestion { + res := &querysvc.OrganizationSuggestion{ + Name: *v.Name, + Domain: *v.Domain, + Logo: v.Logo, + } + + return res +} diff --git a/gen/http/query_svc/client/paths.go b/gen/http/query_svc/client/paths.go index dfd6182..6f016e5 100644 --- a/gen/http/query_svc/client/paths.go +++ b/gen/http/query_svc/client/paths.go @@ -17,6 +17,11 @@ func QueryOrgsQuerySvcPath() string { return "/query/orgs" } +// SuggestOrgsQuerySvcPath returns the URL path to the query-svc service suggest-orgs HTTP endpoint. +func SuggestOrgsQuerySvcPath() string { + return "/query/orgs/suggest" +} + // ReadyzQuerySvcPath returns the URL path to the query-svc service readyz HTTP endpoint. func ReadyzQuerySvcPath() string { return "/readyz" diff --git a/gen/http/query_svc/client/types.go b/gen/http/query_svc/client/types.go index aef197b..478f0ba 100644 --- a/gen/http/query_svc/client/types.go +++ b/gen/http/query_svc/client/types.go @@ -36,6 +36,13 @@ type QueryOrgsResponseBody struct { Employees *string `form:"employees,omitempty" json:"employees,omitempty" xml:"employees,omitempty"` } +// SuggestOrgsResponseBody is the type of the "query-svc" service +// "suggest-orgs" endpoint HTTP response body. +type SuggestOrgsResponseBody struct { + // Organization suggestions + Suggestions []*OrganizationSuggestionResponseBody `form:"suggestions,omitempty" json:"suggestions,omitempty" xml:"suggestions,omitempty"` +} + // QueryResourcesBadRequestResponseBody is the type of the "query-svc" service // "query-resources" endpoint HTTP response body for the "BadRequest" error. type QueryResourcesBadRequestResponseBody struct { @@ -89,6 +96,29 @@ type QueryOrgsServiceUnavailableResponseBody struct { Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` } +// SuggestOrgsBadRequestResponseBody is the type of the "query-svc" service +// "suggest-orgs" endpoint HTTP response body for the "BadRequest" error. +type SuggestOrgsBadRequestResponseBody struct { + // Error message + Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` +} + +// SuggestOrgsInternalServerErrorResponseBody is the type of the "query-svc" +// service "suggest-orgs" endpoint HTTP response body for the +// "InternalServerError" error. +type SuggestOrgsInternalServerErrorResponseBody struct { + // Error message + Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` +} + +// SuggestOrgsServiceUnavailableResponseBody is the type of the "query-svc" +// service "suggest-orgs" endpoint HTTP response body for the +// "ServiceUnavailable" error. +type SuggestOrgsServiceUnavailableResponseBody struct { + // Error message + Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` +} + // ReadyzNotReadyResponseBody is the type of the "query-svc" service "readyz" // endpoint HTTP response body for the "NotReady" error. type ReadyzNotReadyResponseBody struct { @@ -117,6 +147,17 @@ type ResourceResponseBody struct { Data any `form:"data,omitempty" json:"data,omitempty" xml:"data,omitempty"` } +// OrganizationSuggestionResponseBody is used to define fields on response body +// types. +type OrganizationSuggestionResponseBody struct { + // Organization name + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + // Organization domain + Domain *string `form:"domain,omitempty" json:"domain,omitempty" xml:"domain,omitempty"` + // Organization logo URL + Logo *string `form:"logo,omitempty" json:"logo,omitempty" xml:"logo,omitempty"` +} + // NewQueryResourcesResultOK builds a "query-svc" service "query-resources" // endpoint result from a HTTP "OK" response. func NewQueryResourcesResultOK(body *QueryResourcesResponseBody, cacheControl *string) *querysvc.QueryResourcesResult { @@ -216,6 +257,48 @@ func NewQueryOrgsServiceUnavailable(body *QueryOrgsServiceUnavailableResponseBod return v } +// NewSuggestOrgsResultOK builds a "query-svc" service "suggest-orgs" endpoint +// result from a HTTP "OK" response. +func NewSuggestOrgsResultOK(body *SuggestOrgsResponseBody) *querysvc.SuggestOrgsResult { + v := &querysvc.SuggestOrgsResult{} + v.Suggestions = make([]*querysvc.OrganizationSuggestion, len(body.Suggestions)) + for i, val := range body.Suggestions { + v.Suggestions[i] = unmarshalOrganizationSuggestionResponseBodyToQuerysvcOrganizationSuggestion(val) + } + + return v +} + +// NewSuggestOrgsBadRequest builds a query-svc service suggest-orgs endpoint +// BadRequest error. +func NewSuggestOrgsBadRequest(body *SuggestOrgsBadRequestResponseBody) *querysvc.BadRequestError { + v := &querysvc.BadRequestError{ + Message: *body.Message, + } + + return v +} + +// NewSuggestOrgsInternalServerError builds a query-svc service suggest-orgs +// endpoint InternalServerError error. +func NewSuggestOrgsInternalServerError(body *SuggestOrgsInternalServerErrorResponseBody) *querysvc.InternalServerError { + v := &querysvc.InternalServerError{ + Message: *body.Message, + } + + return v +} + +// NewSuggestOrgsServiceUnavailable builds a query-svc service suggest-orgs +// endpoint ServiceUnavailable error. +func NewSuggestOrgsServiceUnavailable(body *SuggestOrgsServiceUnavailableResponseBody) *querysvc.ServiceUnavailableError { + v := &querysvc.ServiceUnavailableError{ + Message: *body.Message, + } + + return v +} + // NewReadyzNotReady builds a query-svc service readyz endpoint NotReady error. func NewReadyzNotReady(body *ReadyzNotReadyResponseBody) *goa.ServiceError { v := &goa.ServiceError{ @@ -239,6 +322,22 @@ func ValidateQueryResourcesResponseBody(body *QueryResourcesResponseBody) (err e return } +// ValidateSuggestOrgsResponseBody runs the validations defined on +// Suggest-OrgsResponseBody +func ValidateSuggestOrgsResponseBody(body *SuggestOrgsResponseBody) (err error) { + if body.Suggestions == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("suggestions", "body")) + } + for _, e := range body.Suggestions { + if e != nil { + if err2 := ValidateOrganizationSuggestionResponseBody(e); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } + return +} + // ValidateQueryResourcesBadRequestResponseBody runs the validations defined on // query-resources_BadRequest_response_body func ValidateQueryResourcesBadRequestResponseBody(body *QueryResourcesBadRequestResponseBody) (err error) { @@ -302,6 +401,33 @@ func ValidateQueryOrgsServiceUnavailableResponseBody(body *QueryOrgsServiceUnava return } +// ValidateSuggestOrgsBadRequestResponseBody runs the validations defined on +// suggest-orgs_BadRequest_response_body +func ValidateSuggestOrgsBadRequestResponseBody(body *SuggestOrgsBadRequestResponseBody) (err error) { + if body.Message == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("message", "body")) + } + return +} + +// ValidateSuggestOrgsInternalServerErrorResponseBody runs the validations +// defined on suggest-orgs_InternalServerError_response_body +func ValidateSuggestOrgsInternalServerErrorResponseBody(body *SuggestOrgsInternalServerErrorResponseBody) (err error) { + if body.Message == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("message", "body")) + } + return +} + +// ValidateSuggestOrgsServiceUnavailableResponseBody runs the validations +// defined on suggest-orgs_ServiceUnavailable_response_body +func ValidateSuggestOrgsServiceUnavailableResponseBody(body *SuggestOrgsServiceUnavailableResponseBody) (err error) { + if body.Message == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("message", "body")) + } + return +} + // ValidateReadyzNotReadyResponseBody runs the validations defined on // readyz_NotReady_response_body func ValidateReadyzNotReadyResponseBody(body *ReadyzNotReadyResponseBody) (err error) { @@ -325,3 +451,15 @@ func ValidateReadyzNotReadyResponseBody(body *ReadyzNotReadyResponseBody) (err e } return } + +// ValidateOrganizationSuggestionResponseBody runs the validations defined on +// OrganizationSuggestionResponseBody +func ValidateOrganizationSuggestionResponseBody(body *OrganizationSuggestionResponseBody) (err error) { + if body.Name == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) + } + if body.Domain == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("domain", "body")) + } + return +} diff --git a/gen/http/query_svc/server/encode_decode.go b/gen/http/query_svc/server/encode_decode.go index 2ea7db1..6b92c6f 100644 --- a/gen/http/query_svc/server/encode_decode.go +++ b/gen/http/query_svc/server/encode_decode.go @@ -297,6 +297,116 @@ func EncodeQueryOrgsError(encoder func(context.Context, http.ResponseWriter) goa } } +// EncodeSuggestOrgsResponse returns an encoder for responses returned by the +// query-svc suggest-orgs endpoint. +func EncodeSuggestOrgsResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { + return func(ctx context.Context, w http.ResponseWriter, v any) error { + res, _ := v.(*querysvc.SuggestOrgsResult) + enc := encoder(ctx, w) + body := NewSuggestOrgsResponseBody(res) + w.WriteHeader(http.StatusOK) + return enc.Encode(body) + } +} + +// DecodeSuggestOrgsRequest returns a decoder for requests sent to the +// query-svc suggest-orgs endpoint. +func DecodeSuggestOrgsRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (any, error) { + return func(r *http.Request) (any, error) { + var ( + version string + query string + bearerToken string + err error + ) + qp := r.URL.Query() + version = qp.Get("v") + if version == "" { + err = goa.MergeErrors(err, goa.MissingFieldError("version", "query string")) + } + if !(version == "1") { + err = goa.MergeErrors(err, goa.InvalidEnumValueError("version", version, []any{"1"})) + } + query = qp.Get("query") + if query == "" { + err = goa.MergeErrors(err, goa.MissingFieldError("query", "query string")) + } + if utf8.RuneCountInString(query) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("query", query, utf8.RuneCountInString(query), 1, true)) + } + bearerToken = r.Header.Get("Authorization") + if bearerToken == "" { + err = goa.MergeErrors(err, goa.MissingFieldError("bearer_token", "header")) + } + if err != nil { + return nil, err + } + payload := NewSuggestOrgsPayload(version, query, bearerToken) + if strings.Contains(payload.BearerToken, " ") { + // Remove authorization scheme prefix (e.g. "Bearer") + cred := strings.SplitN(payload.BearerToken, " ", 2)[1] + payload.BearerToken = cred + } + + return payload, nil + } +} + +// EncodeSuggestOrgsError returns an encoder for errors returned by the +// suggest-orgs query-svc endpoint. +func EncodeSuggestOrgsError(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, formatter func(ctx context.Context, err error) goahttp.Statuser) func(context.Context, http.ResponseWriter, error) error { + encodeError := goahttp.ErrorEncoder(encoder, formatter) + return func(ctx context.Context, w http.ResponseWriter, v error) error { + var en goa.GoaErrorNamer + if !errors.As(v, &en) { + return encodeError(ctx, w, v) + } + switch en.GoaErrorName() { + case "BadRequest": + var res *querysvc.BadRequestError + errors.As(v, &res) + enc := encoder(ctx, w) + var body any + if formatter != nil { + body = formatter(ctx, res) + } else { + body = NewSuggestOrgsBadRequestResponseBody(res) + } + w.Header().Set("goa-error", res.GoaErrorName()) + w.WriteHeader(http.StatusBadRequest) + return enc.Encode(body) + case "InternalServerError": + var res *querysvc.InternalServerError + errors.As(v, &res) + enc := encoder(ctx, w) + var body any + if formatter != nil { + body = formatter(ctx, res) + } else { + body = NewSuggestOrgsInternalServerErrorResponseBody(res) + } + w.Header().Set("goa-error", res.GoaErrorName()) + w.WriteHeader(http.StatusInternalServerError) + return enc.Encode(body) + case "ServiceUnavailable": + var res *querysvc.ServiceUnavailableError + errors.As(v, &res) + enc := encoder(ctx, w) + var body any + if formatter != nil { + body = formatter(ctx, res) + } else { + body = NewSuggestOrgsServiceUnavailableResponseBody(res) + } + w.Header().Set("goa-error", res.GoaErrorName()) + w.WriteHeader(http.StatusServiceUnavailable) + return enc.Encode(body) + default: + return encodeError(ctx, w, v) + } + } +} + // EncodeReadyzResponse returns an encoder for responses returned by the // query-svc readyz endpoint. func EncodeReadyzResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { @@ -363,3 +473,16 @@ func marshalQuerysvcResourceToResourceResponseBody(v *querysvc.Resource) *Resour return res } + +// marshalQuerysvcOrganizationSuggestionToOrganizationSuggestionResponseBody +// builds a value of type *OrganizationSuggestionResponseBody from a value of +// type *querysvc.OrganizationSuggestion. +func marshalQuerysvcOrganizationSuggestionToOrganizationSuggestionResponseBody(v *querysvc.OrganizationSuggestion) *OrganizationSuggestionResponseBody { + res := &OrganizationSuggestionResponseBody{ + Name: v.Name, + Domain: v.Domain, + Logo: v.Logo, + } + + return res +} diff --git a/gen/http/query_svc/server/paths.go b/gen/http/query_svc/server/paths.go index aa917d2..a0a6408 100644 --- a/gen/http/query_svc/server/paths.go +++ b/gen/http/query_svc/server/paths.go @@ -17,6 +17,11 @@ func QueryOrgsQuerySvcPath() string { return "/query/orgs" } +// SuggestOrgsQuerySvcPath returns the URL path to the query-svc service suggest-orgs HTTP endpoint. +func SuggestOrgsQuerySvcPath() string { + return "/query/orgs/suggest" +} + // ReadyzQuerySvcPath returns the URL path to the query-svc service readyz HTTP endpoint. func ReadyzQuerySvcPath() string { return "/readyz" diff --git a/gen/http/query_svc/server/server.go b/gen/http/query_svc/server/server.go index 4a7bd11..a051e6d 100644 --- a/gen/http/query_svc/server/server.go +++ b/gen/http/query_svc/server/server.go @@ -22,6 +22,7 @@ type Server struct { Mounts []*MountPoint QueryResources http.Handler QueryOrgs http.Handler + SuggestOrgs http.Handler Readyz http.Handler Livez http.Handler GenHTTPOpenapiJSON http.Handler @@ -79,6 +80,7 @@ func New( Mounts: []*MountPoint{ {"QueryResources", "GET", "/query/resources"}, {"QueryOrgs", "GET", "/query/orgs"}, + {"SuggestOrgs", "GET", "/query/orgs/suggest"}, {"Readyz", "GET", "/readyz"}, {"Livez", "GET", "/livez"}, {"Serve gen/http/openapi.json", "GET", "/_query/openapi.json"}, @@ -88,6 +90,7 @@ func New( }, QueryResources: NewQueryResourcesHandler(e.QueryResources, mux, decoder, encoder, errhandler, formatter), QueryOrgs: NewQueryOrgsHandler(e.QueryOrgs, mux, decoder, encoder, errhandler, formatter), + SuggestOrgs: NewSuggestOrgsHandler(e.SuggestOrgs, mux, decoder, encoder, errhandler, formatter), Readyz: NewReadyzHandler(e.Readyz, mux, decoder, encoder, errhandler, formatter), Livez: NewLivezHandler(e.Livez, mux, decoder, encoder, errhandler, formatter), GenHTTPOpenapiJSON: http.FileServer(fileSystemGenHTTPOpenapiJSON), @@ -104,6 +107,7 @@ func (s *Server) Service() string { return "query-svc" } func (s *Server) Use(m func(http.Handler) http.Handler) { s.QueryResources = m(s.QueryResources) s.QueryOrgs = m(s.QueryOrgs) + s.SuggestOrgs = m(s.SuggestOrgs) s.Readyz = m(s.Readyz) s.Livez = m(s.Livez) } @@ -115,6 +119,7 @@ func (s *Server) MethodNames() []string { return querysvc.MethodNames[:] } func Mount(mux goahttp.Muxer, h *Server) { MountQueryResourcesHandler(mux, h.QueryResources) MountQueryOrgsHandler(mux, h.QueryOrgs) + MountSuggestOrgsHandler(mux, h.SuggestOrgs) MountReadyzHandler(mux, h.Readyz) MountLivezHandler(mux, h.Livez) MountGenHTTPOpenapiJSON(mux, http.StripPrefix("/_query", h.GenHTTPOpenapiJSON)) @@ -230,6 +235,57 @@ func NewQueryOrgsHandler( }) } +// MountSuggestOrgsHandler configures the mux to serve the "query-svc" service +// "suggest-orgs" endpoint. +func MountSuggestOrgsHandler(mux goahttp.Muxer, h http.Handler) { + f, ok := h.(http.HandlerFunc) + if !ok { + f = func(w http.ResponseWriter, r *http.Request) { + h.ServeHTTP(w, r) + } + } + mux.Handle("GET", "/query/orgs/suggest", f) +} + +// NewSuggestOrgsHandler creates a HTTP handler which loads the HTTP request +// and calls the "query-svc" service "suggest-orgs" endpoint. +func NewSuggestOrgsHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, +) http.Handler { + var ( + decodeRequest = DecodeSuggestOrgsRequest(mux, decoder) + encodeResponse = EncodeSuggestOrgsResponse(encoder) + encodeError = EncodeSuggestOrgsError(encoder, formatter) + ) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), goahttp.AcceptTypeKey, r.Header.Get("Accept")) + ctx = context.WithValue(ctx, goa.MethodKey, "suggest-orgs") + ctx = context.WithValue(ctx, goa.ServiceKey, "query-svc") + payload, err := decodeRequest(r) + if err != nil { + if err := encodeError(ctx, w, err); err != nil { + errhandler(ctx, w, err) + } + return + } + res, err := endpoint(ctx, payload) + if err != nil { + if err := encodeError(ctx, w, err); err != nil { + errhandler(ctx, w, err) + } + return + } + if err := encodeResponse(ctx, w, res); err != nil { + errhandler(ctx, w, err) + } + }) +} + // MountReadyzHandler configures the mux to serve the "query-svc" service // "readyz" endpoint. func MountReadyzHandler(mux goahttp.Muxer, h http.Handler) { diff --git a/gen/http/query_svc/server/types.go b/gen/http/query_svc/server/types.go index cfa9280..8e8e7f8 100644 --- a/gen/http/query_svc/server/types.go +++ b/gen/http/query_svc/server/types.go @@ -36,6 +36,13 @@ type QueryOrgsResponseBody struct { Employees *string `form:"employees,omitempty" json:"employees,omitempty" xml:"employees,omitempty"` } +// SuggestOrgsResponseBody is the type of the "query-svc" service +// "suggest-orgs" endpoint HTTP response body. +type SuggestOrgsResponseBody struct { + // Organization suggestions + Suggestions []*OrganizationSuggestionResponseBody `form:"suggestions" json:"suggestions" xml:"suggestions"` +} + // QueryResourcesBadRequestResponseBody is the type of the "query-svc" service // "query-resources" endpoint HTTP response body for the "BadRequest" error. type QueryResourcesBadRequestResponseBody struct { @@ -89,6 +96,29 @@ type QueryOrgsServiceUnavailableResponseBody struct { Message string `form:"message" json:"message" xml:"message"` } +// SuggestOrgsBadRequestResponseBody is the type of the "query-svc" service +// "suggest-orgs" endpoint HTTP response body for the "BadRequest" error. +type SuggestOrgsBadRequestResponseBody struct { + // Error message + Message string `form:"message" json:"message" xml:"message"` +} + +// SuggestOrgsInternalServerErrorResponseBody is the type of the "query-svc" +// service "suggest-orgs" endpoint HTTP response body for the +// "InternalServerError" error. +type SuggestOrgsInternalServerErrorResponseBody struct { + // Error message + Message string `form:"message" json:"message" xml:"message"` +} + +// SuggestOrgsServiceUnavailableResponseBody is the type of the "query-svc" +// service "suggest-orgs" endpoint HTTP response body for the +// "ServiceUnavailable" error. +type SuggestOrgsServiceUnavailableResponseBody struct { + // Error message + Message string `form:"message" json:"message" xml:"message"` +} + // ReadyzNotReadyResponseBody is the type of the "query-svc" service "readyz" // endpoint HTTP response body for the "NotReady" error. type ReadyzNotReadyResponseBody struct { @@ -117,6 +147,17 @@ type ResourceResponseBody struct { Data any `form:"data,omitempty" json:"data,omitempty" xml:"data,omitempty"` } +// OrganizationSuggestionResponseBody is used to define fields on response body +// types. +type OrganizationSuggestionResponseBody struct { + // Organization name + Name string `form:"name" json:"name" xml:"name"` + // Organization domain + Domain string `form:"domain" json:"domain" xml:"domain"` + // Organization logo URL + Logo *string `form:"logo,omitempty" json:"logo,omitempty" xml:"logo,omitempty"` +} + // NewQueryResourcesResponseBody builds the HTTP response body from the result // of the "query-resources" endpoint of the "query-svc" service. func NewQueryResourcesResponseBody(res *querysvc.QueryResourcesResult) *QueryResourcesResponseBody { @@ -147,6 +188,21 @@ func NewQueryOrgsResponseBody(res *querysvc.Organization) *QueryOrgsResponseBody return body } +// NewSuggestOrgsResponseBody builds the HTTP response body from the result of +// the "suggest-orgs" endpoint of the "query-svc" service. +func NewSuggestOrgsResponseBody(res *querysvc.SuggestOrgsResult) *SuggestOrgsResponseBody { + body := &SuggestOrgsResponseBody{} + if res.Suggestions != nil { + body.Suggestions = make([]*OrganizationSuggestionResponseBody, len(res.Suggestions)) + for i, val := range res.Suggestions { + body.Suggestions[i] = marshalQuerysvcOrganizationSuggestionToOrganizationSuggestionResponseBody(val) + } + } else { + body.Suggestions = []*OrganizationSuggestionResponseBody{} + } + return body +} + // NewQueryResourcesBadRequestResponseBody builds the HTTP response body from // the result of the "query-resources" endpoint of the "query-svc" service. func NewQueryResourcesBadRequestResponseBody(res *querysvc.BadRequestError) *QueryResourcesBadRequestResponseBody { @@ -212,6 +268,33 @@ func NewQueryOrgsServiceUnavailableResponseBody(res *querysvc.ServiceUnavailable return body } +// NewSuggestOrgsBadRequestResponseBody builds the HTTP response body from the +// result of the "suggest-orgs" endpoint of the "query-svc" service. +func NewSuggestOrgsBadRequestResponseBody(res *querysvc.BadRequestError) *SuggestOrgsBadRequestResponseBody { + body := &SuggestOrgsBadRequestResponseBody{ + Message: res.Message, + } + return body +} + +// NewSuggestOrgsInternalServerErrorResponseBody builds the HTTP response body +// from the result of the "suggest-orgs" endpoint of the "query-svc" service. +func NewSuggestOrgsInternalServerErrorResponseBody(res *querysvc.InternalServerError) *SuggestOrgsInternalServerErrorResponseBody { + body := &SuggestOrgsInternalServerErrorResponseBody{ + Message: res.Message, + } + return body +} + +// NewSuggestOrgsServiceUnavailableResponseBody builds the HTTP response body +// from the result of the "suggest-orgs" endpoint of the "query-svc" service. +func NewSuggestOrgsServiceUnavailableResponseBody(res *querysvc.ServiceUnavailableError) *SuggestOrgsServiceUnavailableResponseBody { + body := &SuggestOrgsServiceUnavailableResponseBody{ + Message: res.Message, + } + return body +} + // NewReadyzNotReadyResponseBody builds the HTTP response body from the result // of the "readyz" endpoint of the "query-svc" service. func NewReadyzNotReadyResponseBody(res *goa.ServiceError) *ReadyzNotReadyResponseBody { @@ -252,3 +335,14 @@ func NewQueryOrgsPayload(version string, name *string, domain *string, bearerTok return v } + +// NewSuggestOrgsPayload builds a query-svc service suggest-orgs endpoint +// payload. +func NewSuggestOrgsPayload(version string, query string, bearerToken string) *querysvc.SuggestOrgsPayload { + v := &querysvc.SuggestOrgsPayload{} + v.Version = version + v.Query = query + v.BearerToken = bearerToken + + return v +} diff --git a/gen/query_svc/client.go b/gen/query_svc/client.go index 57e8c15..d67cd62 100644 --- a/gen/query_svc/client.go +++ b/gen/query_svc/client.go @@ -17,15 +17,17 @@ import ( type Client struct { QueryResourcesEndpoint goa.Endpoint QueryOrgsEndpoint goa.Endpoint + SuggestOrgsEndpoint goa.Endpoint ReadyzEndpoint goa.Endpoint LivezEndpoint goa.Endpoint } // NewClient initializes a "query-svc" service client given the endpoints. -func NewClient(queryResources, queryOrgs, readyz, livez goa.Endpoint) *Client { +func NewClient(queryResources, queryOrgs, suggestOrgs, readyz, livez goa.Endpoint) *Client { return &Client{ QueryResourcesEndpoint: queryResources, QueryOrgsEndpoint: queryOrgs, + SuggestOrgsEndpoint: suggestOrgs, ReadyzEndpoint: readyz, LivezEndpoint: livez, } @@ -64,6 +66,22 @@ func (c *Client) QueryOrgs(ctx context.Context, p *QueryOrgsPayload) (res *Organ return ires.(*Organization), nil } +// SuggestOrgs calls the "suggest-orgs" endpoint of the "query-svc" service. +// SuggestOrgs may return the following errors: +// - "BadRequest" (type *BadRequestError): Bad request +// - "NotFound" (type *NotFoundError): Not found +// - "InternalServerError" (type *InternalServerError): Internal server error +// - "ServiceUnavailable" (type *ServiceUnavailableError): Service unavailable +// - error: internal error +func (c *Client) SuggestOrgs(ctx context.Context, p *SuggestOrgsPayload) (res *SuggestOrgsResult, err error) { + var ires any + ires, err = c.SuggestOrgsEndpoint(ctx, p) + if err != nil { + return + } + return ires.(*SuggestOrgsResult), nil +} + // Readyz calls the "readyz" endpoint of the "query-svc" service. // Readyz may return the following errors: // - "NotReady" (type *goa.ServiceError): Service is not ready yet diff --git a/gen/query_svc/endpoints.go b/gen/query_svc/endpoints.go index 96c8188..00e5b42 100644 --- a/gen/query_svc/endpoints.go +++ b/gen/query_svc/endpoints.go @@ -18,6 +18,7 @@ import ( type Endpoints struct { QueryResources goa.Endpoint QueryOrgs goa.Endpoint + SuggestOrgs goa.Endpoint Readyz goa.Endpoint Livez goa.Endpoint } @@ -29,6 +30,7 @@ func NewEndpoints(s Service) *Endpoints { return &Endpoints{ QueryResources: NewQueryResourcesEndpoint(s, a.JWTAuth), QueryOrgs: NewQueryOrgsEndpoint(s, a.JWTAuth), + SuggestOrgs: NewSuggestOrgsEndpoint(s, a.JWTAuth), Readyz: NewReadyzEndpoint(s), Livez: NewLivezEndpoint(s), } @@ -38,6 +40,7 @@ func NewEndpoints(s Service) *Endpoints { func (e *Endpoints) Use(m func(goa.Endpoint) goa.Endpoint) { e.QueryResources = m(e.QueryResources) e.QueryOrgs = m(e.QueryOrgs) + e.SuggestOrgs = m(e.SuggestOrgs) e.Readyz = m(e.Readyz) e.Livez = m(e.Livez) } @@ -80,6 +83,25 @@ func NewQueryOrgsEndpoint(s Service, authJWTFn security.AuthJWTFunc) goa.Endpoin } } +// NewSuggestOrgsEndpoint returns an endpoint function that calls the method +// "suggest-orgs" of service "query-svc". +func NewSuggestOrgsEndpoint(s Service, authJWTFn security.AuthJWTFunc) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + p := req.(*SuggestOrgsPayload) + var err error + sc := security.JWTScheme{ + Name: "jwt", + Scopes: []string{}, + RequiredScopes: []string{}, + } + ctx, err = authJWTFn(ctx, p.BearerToken, &sc) + if err != nil { + return nil, err + } + return s.SuggestOrgs(ctx, p) + } +} + // NewReadyzEndpoint returns an endpoint function that calls the method // "readyz" of service "query-svc". func NewReadyzEndpoint(s Service) goa.Endpoint { diff --git a/gen/query_svc/service.go b/gen/query_svc/service.go index 7ce7f95..c3f38cb 100644 --- a/gen/query_svc/service.go +++ b/gen/query_svc/service.go @@ -21,6 +21,8 @@ type Service interface { QueryResources(context.Context, *QueryResourcesPayload) (res *QueryResourcesResult, err error) // Locate a single organization by name or domain. QueryOrgs(context.Context, *QueryOrgsPayload) (res *Organization, err error) + // Get organization suggestions for typeahead search based on a query. + SuggestOrgs(context.Context, *SuggestOrgsPayload) (res *SuggestOrgsResult, err error) // Check if the service is able to take inbound requests. Readyz(context.Context) (res []byte, err error) // Check if the service is alive. @@ -47,7 +49,7 @@ const ServiceName = "query-svc" // MethodNames lists the service method names as defined in the design. These // are the same values that are set in the endpoint request contexts under the // MethodKey key. -var MethodNames = [4]string{"query-resources", "query-orgs", "readyz", "livez"} +var MethodNames = [5]string{"query-resources", "query-orgs", "suggest-orgs", "readyz", "livez"} type BadRequestError struct { // Error message @@ -78,6 +80,16 @@ type Organization struct { Employees *string } +// An organization suggestion for typeahead search. +type OrganizationSuggestion struct { + // Organization name + Name string + // Organization domain + Domain string + // Organization logo URL + Logo *string +} + // QueryOrgsPayload is the payload type of the query-svc service query-orgs // method. type QueryOrgsPayload struct { @@ -138,6 +150,24 @@ type ServiceUnavailableError struct { Message string } +// SuggestOrgsPayload is the payload type of the query-svc service suggest-orgs +// method. +type SuggestOrgsPayload struct { + // Token + BearerToken string + // Version of the API + Version string + // Search query for organization suggestions + Query string +} + +// SuggestOrgsResult is the result type of the query-svc service suggest-orgs +// method. +type SuggestOrgsResult struct { + // Organization suggestions + Suggestions []*OrganizationSuggestion +} + // Error returns an error description. func (e *BadRequestError) Error() string { return "" diff --git a/internal/domain/model/organization.go b/internal/domain/model/organization.go index a1bd836..fdaa423 100644 --- a/internal/domain/model/organization.go +++ b/internal/domain/model/organization.go @@ -16,3 +16,19 @@ type Organization struct { // Employee count or range Employees string `json:"employees"` } + +// OrganizationSuggestion represents a suggested organization for typeahead search +type OrganizationSuggestion struct { + // Organization name + Name string `json:"name"` + // Organization domain + Domain string `json:"domain"` + // Organization logo URL + Logo *string `json:"logo,omitempty"` +} + +// OrganizationSuggestionsResult contains the results of an organization suggestions search +type OrganizationSuggestionsResult struct { + // Suggestions found + Suggestions []OrganizationSuggestion `json:"suggestions"` +} diff --git a/internal/domain/model/search_criteria.go b/internal/domain/model/search_criteria.go index d58b83b..ad02998 100644 --- a/internal/domain/model/search_criteria.go +++ b/internal/domain/model/search_criteria.go @@ -48,3 +48,9 @@ type OrganizationSearchCriteria struct { // Organization domain or website URL Domain *string } + +// OrganizationSuggestionCriteria encapsulates search parameters for organization suggestions +type OrganizationSuggestionCriteria struct { + // Search query for organization suggestions + Query string +} diff --git a/internal/domain/port/searcher.go b/internal/domain/port/searcher.go index 057b1eb..76cdfd5 100644 --- a/internal/domain/port/searcher.go +++ b/internal/domain/port/searcher.go @@ -27,6 +27,9 @@ type OrganizationSearcher interface { // QueryOrganizations searches for organizations based on the provided criteria QueryOrganizations(ctx context.Context, criteria model.OrganizationSearchCriteria) (*model.Organization, error) + // SuggestOrganizations returns organization suggestions for typeahead search + SuggestOrganizations(ctx context.Context, criteria model.OrganizationSuggestionCriteria) (*model.OrganizationSuggestionsResult, error) + // IsReady checks if the search service is ready IsReady(ctx context.Context) error } diff --git a/internal/infrastructure/clearbit/client.go b/internal/infrastructure/clearbit/client.go index 264921c..55ad9f5 100644 --- a/internal/infrastructure/clearbit/client.go +++ b/internal/infrastructure/clearbit/client.go @@ -32,7 +32,12 @@ func (c *Client) FindCompanyByName(ctx context.Context, name string) (*ClearbitC q.Set("name", name) u.RawQuery = q.Encode() - return c.makeRequest(ctx, u.String()) + var company ClearbitCompany + err = c.makeRequest(ctx, u.String(), &company) + if err != nil { + return nil, err + } + return &company, nil } // FindCompanyByDomain searches for a company by domain using Clearbit's Company API @@ -47,11 +52,36 @@ func (c *Client) FindCompanyByDomain(ctx context.Context, domain string) (*Clear q.Set("domain", domain) u.RawQuery = q.Encode() - return c.makeRequest(ctx, u.String()) + var company ClearbitCompany + err = c.makeRequest(ctx, u.String(), &company) + if err != nil { + return nil, err + } + return &company, nil +} + +// SuggestCompanies searches for company suggestions using Clearbit's Autocomplete API +func (c *Client) SuggestCompanies(ctx context.Context, query string) ([]ClearbitCompanySuggestion, error) { + // Build the URL with query parameters + u, err := url.Parse(fmt.Sprintf("%s/v1/companies/suggest", c.config.AutocompleteBaseURL)) + if err != nil { + return nil, fmt.Errorf("failed to parse autocomplete base URL: %w", err) + } + + q := u.Query() + q.Set("query", query) + u.RawQuery = q.Encode() + + var suggestions []ClearbitCompanySuggestion + err = c.makeRequest(ctx, u.String(), &suggestions) + if err != nil { + return nil, err + } + return suggestions, nil } // makeRequest performs the HTTP request to Clearbit API using the generic HTTP client -func (c *Client) makeRequest(ctx context.Context, url string) (*ClearbitCompany, error) { +func (c *Client) makeRequest(ctx context.Context, url string, model any) error { headers := map[string]string{ "Authorization": fmt.Sprintf("Bearer %s", c.config.APIKey), } @@ -62,20 +92,21 @@ func (c *Client) makeRequest(ctx context.Context, url string) (*ClearbitCompany, if httpErr, ok := err.(*httpclient.RetryableError); ok { switch httpErr.StatusCode { case http.StatusNotFound: - return nil, errors.NewNotFound("company not found") + return errors.NewNotFound("company not found") + case http.StatusBadRequest, http.StatusUnprocessableEntity: + return errors.NewValidation("invalid request", err) default: - return nil, errors.NewUnexpected("unexpected error", err) + return errors.NewUnexpected("unexpected error", err) } } - return nil, errors.NewUnexpected("request failed", err) + return errors.NewUnexpected("request failed", err) } - var company ClearbitCompany - if err := json.Unmarshal(resp.Body, &company); err != nil { - return nil, errors.NewUnexpected("failed to decode response", err) + if err := json.Unmarshal(resp.Body, &model); err != nil { + return errors.NewUnexpected("failed to decode response", err) } - return &company, nil + return nil } // IsReady checks if the Clearbit API is reachable diff --git a/internal/infrastructure/clearbit/config.go b/internal/infrastructure/clearbit/config.go index d182e0c..c88b4b0 100644 --- a/internal/infrastructure/clearbit/config.go +++ b/internal/infrastructure/clearbit/config.go @@ -9,7 +9,8 @@ import ( ) var ( - defaultBaseURL = "https://company.clearbit.com" + defaultCompanyBaseURL = "https://company.clearbit.com" + defaultAutocompleteBaseURL = "https://autocomplete.clearbit.com" ) // Config holds the configuration for Clearbit API client @@ -17,9 +18,12 @@ type Config struct { // APIKey is the Clearbit API key for authentication APIKey string - // BaseURL is the base URL for Clearbit API (default: https://company.clearbit.com) + // BaseURL is the base URL for Clearbit Company API (default: https://company.clearbit.com) BaseURL string + // AutocompleteBaseURL is the base URL for Clearbit Autocomplete API (default: https://autocomplete.clearbit.com) + AutocompleteBaseURL string + // Timeout is the HTTP client timeout for API requests Timeout time.Duration @@ -33,15 +37,16 @@ type Config struct { // DefaultConfig returns a Config with sensible defaults func DefaultConfig() Config { return Config{ - BaseURL: "https://company.clearbit.com", - Timeout: 30 * time.Second, - MaxRetries: 3, - RetryDelay: 1 * time.Second, + BaseURL: defaultCompanyBaseURL, + AutocompleteBaseURL: defaultAutocompleteBaseURL, + Timeout: 30 * time.Second, + MaxRetries: 3, + RetryDelay: 1 * time.Second, } } // NewConfig creates a new Clearbit configuration with the provided parameters -func NewConfig(apiKey, baseURL, timeout string, maxRetries int, retryDelay string) (Config, error) { +func NewConfig(apiKey, baseURL, autocompleteBaseURL, timeout string, maxRetries int, retryDelay string) (Config, error) { // Validate required parameters if apiKey == "" { return Config{}, fmt.Errorf("API key is required for Clearbit configuration") @@ -49,7 +54,11 @@ func NewConfig(apiKey, baseURL, timeout string, maxRetries int, retryDelay strin // Set defaults for optional parameters if baseURL == "" { - baseURL = defaultBaseURL + baseURL = defaultCompanyBaseURL + } + + if autocompleteBaseURL == "" { + autocompleteBaseURL = defaultAutocompleteBaseURL } if timeout == "" { @@ -73,10 +82,11 @@ func NewConfig(apiKey, baseURL, timeout string, maxRetries int, retryDelay strin } return Config{ - APIKey: apiKey, - BaseURL: baseURL, - Timeout: timeoutDuration, - MaxRetries: maxRetries, - RetryDelay: retryDelayDuration, + APIKey: apiKey, + BaseURL: baseURL, + AutocompleteBaseURL: autocompleteBaseURL, + Timeout: timeoutDuration, + MaxRetries: maxRetries, + RetryDelay: retryDelayDuration, }, nil } diff --git a/internal/infrastructure/clearbit/models.go b/internal/infrastructure/clearbit/models.go index d3ad93a..336bdbd 100644 --- a/internal/infrastructure/clearbit/models.go +++ b/internal/infrastructure/clearbit/models.go @@ -56,3 +56,15 @@ type ClearbitError struct { Type string `json:"type"` Message string `json:"message"` } + +// ClearbitCompanySuggestion represents a company suggestion from the autocomplete API +type ClearbitCompanySuggestion struct { + // Name is the company name + Name string `json:"name"` + + // Domain is the company's primary domain + Domain string `json:"domain"` + + // Logo is the URL to the company's logo (can be null) + Logo *string `json:"logo"` +} diff --git a/internal/infrastructure/clearbit/searcher.go b/internal/infrastructure/clearbit/searcher.go index c64ac45..bbe9322 100644 --- a/internal/infrastructure/clearbit/searcher.go +++ b/internal/infrastructure/clearbit/searcher.go @@ -48,25 +48,22 @@ func (s *OrganizationSearcher) QueryOrganizations(ctx context.Context, criteria } // search by domain again to enrich the organization if clearbitCompany != nil && clearbitCompany.Domain != "" { - clearbitCompany, err = s.client.FindCompanyByDomain(ctx, clearbitCompany.Domain) - if err == nil { + clearbitCompanyEnriched, errFindCompanyByDomain := s.client.FindCompanyByDomain(ctx, clearbitCompany.Domain) + if errFindCompanyByDomain == nil { slog.DebugContext(ctx, "found organization by domain", "name", clearbitCompany.Name) + clearbitCompany = clearbitCompanyEnriched } } } - if clearbitCompany == nil { + if err != nil { slog.ErrorContext(ctx, "error searching organization", "error", err) - if criteria.Name != nil && criteria.Domain != nil { - return nil, errors.NewNotFound(fmt.Sprintf("organization not found with name '%s' or domain '%s'", *criteria.Name, *criteria.Domain), err) - } - if criteria.Name != nil { - return nil, errors.NewNotFound(fmt.Sprintf("organization not found with name '%s'", *criteria.Name), err) - } - if criteria.Domain != nil { - return nil, errors.NewNotFound(fmt.Sprintf("organization not found with domain '%s'", *criteria.Domain), err) - } - return nil, errors.NewNotFound("no search criteria provided", err) + return nil, err + } + + if clearbitCompany == nil { + slog.ErrorContext(ctx, "organization not found", "error", err) + return nil, errors.NewNotFound("organization not found") } // Convert Clearbit company to domain model @@ -81,6 +78,41 @@ func (s *OrganizationSearcher) QueryOrganizations(ctx context.Context, criteria return org, nil } +// SuggestOrganizations returns organization suggestions using Clearbit Autocomplete API +func (s *OrganizationSearcher) SuggestOrganizations(ctx context.Context, criteria model.OrganizationSuggestionCriteria) (*model.OrganizationSuggestionsResult, error) { + slog.DebugContext(ctx, "searching organization suggestions via Clearbit Autocomplete API", + "query", criteria.Query, + ) + + // Call the Clearbit Autocomplete API + clearbitSuggestions, err := s.client.SuggestCompanies(ctx, criteria.Query) + if err != nil { + slog.ErrorContext(ctx, "error searching organization suggestions", "error", err) + return nil, err + } + + // Convert to domain model + suggestions := make([]model.OrganizationSuggestion, len(clearbitSuggestions)) + for i, suggestion := range clearbitSuggestions { + suggestions[i] = model.OrganizationSuggestion{ + Name: suggestion.Name, + Domain: suggestion.Domain, + Logo: suggestion.Logo, + } + } + + result := &model.OrganizationSuggestionsResult{ + Suggestions: suggestions, + } + + slog.DebugContext(ctx, "successfully found organization suggestions", + "query", criteria.Query, + "count", len(suggestions), + ) + + return result, nil +} + // convertToDomainModel converts a Clearbit company to the domain model func (s *OrganizationSearcher) convertToDomainModel(company *ClearbitCompany) *model.Organization { org := &model.Organization{ diff --git a/internal/infrastructure/mock/organization_searcher.go b/internal/infrastructure/mock/organization_searcher.go index 256d461..0c1b6a0 100644 --- a/internal/infrastructure/mock/organization_searcher.go +++ b/internal/infrastructure/mock/organization_searcher.go @@ -124,6 +124,43 @@ func (m *MockOrganizationSearcher) QueryOrganizations(ctx context.Context, crite return nil, errors.NewValidation("no search criteria provided") } +// SuggestOrganizations implements the OrganizationSearcher interface with mock suggestions +func (m *MockOrganizationSearcher) SuggestOrganizations(ctx context.Context, criteria model.OrganizationSuggestionCriteria) (*model.OrganizationSuggestionsResult, error) { + slog.DebugContext(ctx, "executing mock organization suggestions search", + "query", criteria.Query, + ) + + var suggestions []model.OrganizationSuggestion + query := strings.ToLower(criteria.Query) + + // Search for organizations that match the query (case-insensitive partial match) + for _, org := range m.organizations { + if strings.Contains(strings.ToLower(org.Name), query) || strings.Contains(strings.ToLower(org.Domain), query) { + suggestions = append(suggestions, model.OrganizationSuggestion{ + Name: org.Name, + Domain: org.Domain, + Logo: nil, // Mock doesn't have logo data + }) + } + } + + // Limit to first 5 suggestions for realistic behavior + if len(suggestions) > 5 { + suggestions = suggestions[:5] + } + + result := &model.OrganizationSuggestionsResult{ + Suggestions: suggestions, + } + + slog.DebugContext(ctx, "mock organization suggestions search completed", + "query", criteria.Query, + "suggestion_count", len(suggestions), + ) + + return result, nil +} + // IsReady implements the OrganizationSearcher interface (always ready for mock) func (m *MockOrganizationSearcher) IsReady(ctx context.Context) error { return nil diff --git a/internal/service/organization_search.go b/internal/service/organization_search.go index c8b734a..2a059f9 100644 --- a/internal/service/organization_search.go +++ b/internal/service/organization_search.go @@ -18,6 +18,9 @@ type OrganizationSearcher interface { // QueryOrganizations searches for organizations based on the provided criteria QueryOrganizations(ctx context.Context, criteria model.OrganizationSearchCriteria) (*model.Organization, error) + // SuggestOrganizations returns organization suggestions for typeahead search + SuggestOrganizations(ctx context.Context, criteria model.OrganizationSuggestionCriteria) (*model.OrganizationSuggestionsResult, error) + // IsReady checks if the search service is ready IsReady(ctx context.Context) error } @@ -59,6 +62,35 @@ func (s *OrganizationSearch) QueryOrganizations(ctx context.Context, criteria mo return result, nil } +// SuggestOrganizations performs organization suggestions with business logic validation +func (s *OrganizationSearch) SuggestOrganizations(ctx context.Context, criteria model.OrganizationSuggestionCriteria) (*model.OrganizationSuggestionsResult, error) { + + slog.DebugContext(ctx, "starting organization suggestions search", + "query", criteria.Query, + ) + + // Delegate to the search implementation + result, err := s.organizationSearcher.SuggestOrganizations(ctx, criteria) + if err != nil { + slog.ErrorContext(ctx, "organization suggestions search operation failed", + "error", err, + ) + return nil, err + } + + var suggestionCount int + if result != nil { + suggestionCount = len(result.Suggestions) + } + + slog.DebugContext(ctx, "organization suggestions search completed", + "query", criteria.Query, + "suggestion_count", suggestionCount, + ) + + return result, nil +} + func (s *OrganizationSearch) IsReady(ctx context.Context) error { if err := s.organizationSearcher.IsReady(ctx); err != nil { return err diff --git a/pkg/httpclient/client.go b/pkg/httpclient/client.go index fff094e..1b878dc 100644 --- a/pkg/httpclient/client.go +++ b/pkg/httpclient/client.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "io" + "log/slog" "net/http" "strings" "time" @@ -40,7 +41,7 @@ type RetryableError struct { } func (e *RetryableError) Error() string { - return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Message) + return e.Message } // Do executes an HTTP request with retry logic @@ -75,7 +76,9 @@ func (c *Client) Do(ctx context.Context, req Request) (*Response, error) { } } - return nil, fmt.Errorf("request failed %w", lastErr) + slog.ErrorContext(ctx, "request failed", "error", lastErr) + + return nil, lastErr } // doRequest performs a single HTTP request @@ -111,7 +114,7 @@ func (c *Client) doRequest(ctx context.Context, reqConfig Request) (*Response, e } // Check for HTTP errors - if resp.StatusCode >= 400 { + if resp.StatusCode >= http.StatusBadRequest { err := &RetryableError{ StatusCode: resp.StatusCode, Message: string(body), @@ -131,7 +134,7 @@ func (c *Client) shouldRetry(err error) bool { // Check if it's a retryable error if retryableErr, ok := err.(*RetryableError); ok { // Retry on server errors and rate limiting - return retryableErr.StatusCode >= 500 || retryableErr.StatusCode == 429 + return retryableErr.StatusCode >= http.StatusInternalServerError || retryableErr.StatusCode == http.StatusTooManyRequests } // Retry on network-related errors