This commit is contained in:
Alex Shevchuk
2025-08-18 17:12:04 +03:00
commit d84487d238
157 changed files with 160686 additions and 0 deletions

View File

@@ -0,0 +1,313 @@
package integration
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/cache"
cache2 "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/cache"
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database"
dberrors "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/errors"
)
type Client interface {
HandleVacancyResponse(request HandleVacancy) error
}
type (
CompanySecrets any
client struct {
cache cache2.Client
db database.Client
logger *slog.Logger
secrets map[string]CompanySecrets
}
Config struct {
Cache cache2.Client
Db database.Client
Logger *slog.Logger
Secrets map[string]CompanySecrets
}
)
func New(c Config) (Client, error) {
return &client{
cache: c.Cache,
db: c.Db,
logger: c.Logger,
secrets: c.Secrets,
}, nil
}
// TODO: need to think about possible errors
// e.g. cache is down, connection lost etc.
func (c *client) HandleVacancyResponse(request HandleVacancy) error {
const handlerName = "integration.HandleVacancyResponse"
companyId, vacancyExtraFields, err := c.getVacancyIntegrationInfo(context.Background(), request.VacancyId)
if err != nil {
return err
}
if vacancyExtraFields == nil {
c.logger.Debug("vacancy extra fields are empty",
slog.String("vacancy_id", request.VacancyId),
slog.String("handler", handlerName),
)
return nil
}
companyMetadata, companyExtraFieldsTemplate, err := c.getCompanyIntegrationInfo(context.Background(), companyId)
if err != nil {
return err
}
if companyMetadata == nil {
c.logger.Debug("company metadata is empty",
slog.String("company_id", companyId),
slog.String("handler", handlerName),
)
return nil
}
if companyExtraFieldsTemplate == nil {
c.logger.Debug("company extra fields template are empty",
slog.String("company_id", companyId),
slog.String("handler", handlerName),
)
return nil
}
companyName, ok := (*companyMetadata)[companyNameMetadataKey]
if !ok {
return fmt.Errorf("company name not found in metadata")
}
switch companyName {
case VkusvillCompanyName:
return c.handleVkusvillIntegration(
context.Background(),
request,
vacancyExtraFields,
companyMetadata,
companyExtraFieldsTemplate,
)
default:
return ErrUnknownIntegration
}
}
//nolint:gocognit // not that hard
func (c *client) getVacancyIntegrationInfo(
ctx context.Context,
vacancyId string,
) (string, *VacancyExtraFields, error) {
const handlerName = "integration.getVacancyIntegrationInfo"
var (
companyId string
vacancyExtraFields VacancyExtraFields
gotCached = true
)
// get company id and vacancy filled template
cachedIntegrationInfo, err := c.cache.Get(context.Background(), vacancyId, cache.IntegrationVacancyValueType)
if err != nil && !errors.Is(err, cache.ErrKeyNotFound) {
if err := tryRepeat(context.Background(), func() error {
cachedIntegrationInfo, err = c.cache.Get(context.Background(), vacancyId, cache.IntegrationCompanyValueType)
return err
}, defaultRetryAmount); err != nil {
// TODO: maybe we should process further and try DB instead
// Need to handle the error deeper
// e.g. if cache is down we can try to get info from DB
// on the other hand, if there is any other fatal error, we should return it
return "", nil, fmt.Errorf("error getting vacancy integration info from cache: %w", err)
}
}
if errors.Is(err, cache.ErrKeyNotFound) {
// try get data from DB
dbCompanyId, dbFields, err := c.db.GetVacancyIntegrationInfoById(context.Background(), vacancyId)
if err != nil {
if errors.Is(err, dberrors.ErrNotFound) {
return "", nil, ErrVacancyNotFound
}
ctx, cancel := context.WithTimeout(context.Background(), defaultRetryTimeout)
defer cancel()
if err := tryRepeat(ctx, func() error {
dbCompanyId, dbFields, err = c.db.GetVacancyIntegrationInfoById(context.Background(), vacancyId)
return err
}, 0); err != nil {
return "", nil, fmt.Errorf("error getting vacancy integration info from DB: %w", err)
}
}
companyId = dbCompanyId
vacancyExtraFields = *new(VacancyExtraFields).From(dbFields)
gotCached = false
// store obtained info in cache
fullMetadata := VacancyIntegrationInfo{
CompanyId: companyId,
ExtraFields: vacancyExtraFields,
}
jsonMetadata, err := json.Marshal(fullMetadata)
if err != nil {
c.logger.Error("error marshalling vacancy integration info",
slog.String("error", err.Error()),
slog.String("vacancy_id", vacancyId),
slog.String("handler", handlerName))
} else {
if err := tryRepeat(context.Background(), func() error {
return c.cache.Set(context.Background(), vacancyId, cache.IntegrationVacancyValueType, string(jsonMetadata), 0)
}, defaultRetryAmount); err != nil {
c.logger.Error("error saving vacancy integration info to cache",
slog.String("error", err.Error()),
slog.String("vacancy_id", vacancyId),
slog.String("handler", handlerName))
}
}
}
if gotCached {
var fullMetadata VacancyIntegrationInfo
if err := json.Unmarshal([]byte(cachedIntegrationInfo), &fullMetadata); err != nil {
return "", nil, fmt.Errorf("error unmarshalling cached metadata: %w", err)
}
companyId = fullMetadata.CompanyId
vacancyExtraFields = fullMetadata.ExtraFields
}
return companyId, &vacancyExtraFields, nil
}
//nolint:gocognit // not that hard
func (c *client) getCompanyIntegrationInfo(
ctx context.Context,
companyId string,
) (*CompanyMetadata, *CompanyExtraFieldsTemplate, error) {
const handlerName = "integration.getCompanyIntegrationInfo"
var (
companyMetadata CompanyMetadata
companyFieldsTemplate CompanyExtraFieldsTemplate
gotCached = true
)
// get company metadata and template
cachedIntegrationInfo, err := c.cache.Get(context.Background(), companyId, cache.IntegrationCompanyValueType)
if err != nil && !errors.Is(err, cache.ErrKeyNotFound) {
if err := tryRepeat(context.Background(), func() error {
cachedIntegrationInfo, err = c.cache.Get(context.Background(), companyId, cache.IntegrationCompanyValueType)
return err
}, defaultRetryAmount); err != nil {
// TODO: maybe we should process further and try DB instead
// Need to handle the error deeper
// e.g. if cache is down we can try to get info from DB
// on the other hand, if there is any other fatal error, we should return it
return nil, nil, fmt.Errorf("error getting company metadata from cache: %w", err)
}
}
if errors.Is(err, cache.ErrKeyNotFound) {
// try get data from DB
dbMetadata, dbFieldsTemplate, err := c.db.GetCompanyMetadataById(context.Background(), companyId)
if err != nil {
if errors.Is(err, dberrors.ErrNotFound) {
return nil, nil, ErrCompanyNotFound
}
// TODO: think about other retry policies
ctx, cancel := context.WithTimeout(context.Background(), defaultRetryTimeout)
defer cancel()
if err := tryRepeat(ctx, func() error {
dbMetadata, dbFieldsTemplate, err = c.db.GetCompanyMetadataById(context.Background(), companyId)
return err
}, 0); err != nil {
return nil, nil, fmt.Errorf("error getting company integration info from DB: %w", err)
}
}
companyMetadata = *new(CompanyMetadata).From(dbMetadata)
companyFieldsTemplate = *new(CompanyExtraFieldsTemplate).From(dbFieldsTemplate)
gotCached = false
// store obtained info in cache
fullMetadata := CompanyIntegrationInfo{
Metadata: companyMetadata,
ExtraFieldsTemplate: companyFieldsTemplate,
}
jsonMetadata, err := json.Marshal(fullMetadata)
if err != nil {
c.logger.Error("error marshalling company integration info",
slog.String("error", err.Error()),
slog.String("company_id", companyId),
slog.String("handler", handlerName))
} else {
if err := tryRepeat(context.Background(), func() error {
return c.cache.Set(context.Background(), companyId, cache.IntegrationCompanyValueType, string(jsonMetadata), 0)
}, defaultRetryAmount); err != nil {
c.logger.Error("error saving company integration info to cache",
slog.String("error", err.Error()),
slog.String("company_id", companyId),
slog.String("handler", handlerName))
}
}
}
if gotCached {
var fullMetadata CompanyIntegrationInfo
if err := json.Unmarshal([]byte(cachedIntegrationInfo), &fullMetadata); err != nil {
return nil, nil, fmt.Errorf("error unmarshalling cached metadata: %w", err)
}
companyMetadata = fullMetadata.Metadata
companyFieldsTemplate = fullMetadata.ExtraFieldsTemplate
}
return &companyMetadata, &companyFieldsTemplate, nil
}
// tryRepeat tries to execute function N times.
// If number of attempts is 0, the function will be executed until context is canceled
func tryRepeat(ctx context.Context, fn func() error, attempts uint) error {
ch := make(chan error, 1)
for iter := uint(0); iter < attempts || attempts == 0; iter++ {
go func() {
ch <- fn()
}()
select {
case err := <-ch:
if err == nil {
return nil
}
if attempts != 0 && iter == attempts-1 {
return err
}
case <-ctx.Done():
return ctx.Err()
}
}
return nil
}

View File

@@ -0,0 +1,8 @@
package integration
import "time"
const (
defaultRetryAmount = 5
defaultRetryTimeout = 20 * time.Minute
)

View File

@@ -0,0 +1,201 @@
package integration
import (
"strconv"
dbtypes "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/types"
)
type (
Candidate struct {
Id string
Name string
Email string
Phone string
Birthday string
Info string
CvLink string
}
HandleVacancy struct {
AgentId string
VacancyId string
SourceLid string
Candidate Candidate
}
)
type FieldType string
const (
BooleanFieldType FieldType = "bool"
IntegerFieldType FieldType = "int"
FloatFieldType FieldType = "float"
StringFieldType FieldType = "string"
StructFieldType FieldType = "struct"
)
func convertFieldtype(typ FieldType, value string) (any, error) {
switch typ {
case BooleanFieldType:
return strconv.ParseBool(value)
case IntegerFieldType:
return strconv.ParseInt(value, 10, 64)
case FloatFieldType:
return strconv.ParseFloat(value, 64)
case StringFieldType:
return value, nil
case StructFieldType:
// TODO: maybe convert it to map[string]any?
return value, nil
default:
return nil, ErrUnknownFieldType
}
}
type (
CompanyMetadata map[string]string
ValidValue struct {
DisplayName string `json:"display_name"`
Value string `json:"value"`
}
/*
CompanyExtraFieldTemplate describes single
additional field of company additional fields template
*/
CompanyExtraFieldTemplate struct {
SystemName string `json:"system_name"`
DisplayName string `json:"display_name"`
Type FieldType `json:"type"`
Required bool `json:"required"`
MultipleChoice bool `json:"multiple_choice"`
ValidValues []ValidValue `json:"valid_values"`
}
/*
CompanyExtraFieldsTemplate describes additional fields
needed to integrate company
*/
CompanyExtraFieldsTemplate struct {
Fields []CompanyExtraFieldTemplate `json:"fields"`
}
/*
CompanyIntegrationInfo describes full metadata stored for company
*/
CompanyIntegrationInfo struct {
Metadata CompanyMetadata `json:"metadata"`
ExtraFieldsTemplate CompanyExtraFieldsTemplate `json:"template"`
}
)
func (m *CompanyMetadata) From(msg *dbtypes.CompanyMetadata) *CompanyMetadata {
if msg == nil {
return nil
}
for k, v := range *msg {
(*m)[k] = v
}
return m
}
func (t *CompanyExtraFieldsTemplate) From(msg *dbtypes.CompanyExtraFieldsTemplate) *CompanyExtraFieldsTemplate {
if msg == nil {
return nil
}
var res CompanyExtraFieldsTemplate
res.Fields = make([]CompanyExtraFieldTemplate, len(msg.Fields))
for i, v := range msg.Fields {
res.Fields[i] = CompanyExtraFieldTemplate{
SystemName: v.SystemName,
DisplayName: v.DisplayName,
Type: FieldType(v.Type),
Required: v.Required,
MultipleChoice: v.MultipleChoice,
ValidValues: make([]ValidValue, len(v.ValidValues)),
}
for j, vv := range v.ValidValues {
res.Fields[i].ValidValues[j] = ValidValue{
DisplayName: vv.DisplayName,
Value: vv.Value,
}
}
}
return &res
}
type (
/*
VacancyExtraField describes single filled
additional field of vacancy additional fields
*/
VacancyExtraField struct {
SystemName string `json:"system_name"`
Type FieldType `json:"type"`
MultipleChoice bool `json:"multiple_choice"`
IsValidValue bool `json:"is_valid_value"`
Value []string `json:"value"`
}
/*
VacancyExtraFields describes additional fields
filled during vacancy creation
*/
VacancyExtraFields struct {
Fields []VacancyExtraField `json:"fields"`
}
/*
VacancyIntegrationInfo describes full metadata stored for vacancy
*/
VacancyIntegrationInfo struct {
CompanyId string `json:"company_id"`
ExtraFields VacancyExtraFields `json:"extra_fields"`
}
)
func (f *VacancyExtraField) From(msg *dbtypes.VacancyExtraFieldTemplate) *VacancyExtraField {
if msg == nil {
return nil
}
res := VacancyExtraField{
SystemName: msg.SystemName,
Type: FieldType(msg.Type),
IsValidValue: msg.IsValidValue,
Value: msg.Value,
}
return &res
}
func (f *VacancyExtraFields) From(msg *dbtypes.VacancyExtraFieldsTemplate) *VacancyExtraFields {
if msg == nil {
return nil
}
var res VacancyExtraFields
res.Fields = make([]VacancyExtraField, len(msg.Fields))
for i, v := range msg.Fields {
res.Fields[i] = *new(VacancyExtraField).From(&v)
}
return &res
}
const (
companyNameMetadataKey = "company_name"
VkusvillCompanyName = "vkusvill"
)

View File

@@ -0,0 +1,12 @@
package integration
import "errors"
var (
ErrInvalidConfig = errors.New("invalid config")
ErrUnknownIntegration = errors.New("unknown integration company")
ErrUnknownFieldType = errors.New("unknown field type")
ErrUnknownCandidateStatus = errors.New("unknown candidate status")
ErrVacancyNotFound = errors.New("vacancy not found")
ErrCompanyNotFound = errors.New("company not found")
)

View File

@@ -0,0 +1,344 @@
package integration
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
rmodel "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/request_model"
)
const (
vkusvillMolvaUtmSource = "molva"
dmYFormat = "02.01.2006" // datetime format stored in DB
YmdFormat = "2006-01-02" // datetime format used in vkusvill
)
var (
// TODO: use reflect to set json tags
vkusvillVacancyIdFieldKey = "vacancy_id"
vkusvillNeededFields = map[string]struct{}{
vkusvillVacancyIdFieldKey: {},
}
)
type (
VkusvillSecretsConfig struct {
ApiToken string
}
)
type (
vkusvillSourceLid struct {
Url string `json:"url"`
}
vkusvillSaveCandidateRequest struct {
Status int `json:"status"` // see status mapping
LastName string `json:"last_name"` // required
Name string `json:"name"` // required
SecondName string `json:"second_name"` // required
Birthday string `json:"birthday"` // YYYY-mm-dd
Phone []string `json:"phone"`
VacancyId int `json:"vacancy_id"` // see vacancy to id mapping
CityJob string `json:"city_job"` // unsupported now
MetroArea string `json:"metro_area"` // unsupported now
UtmSource string `json:"utm_source"`
SourceLid vkusvillSourceLid `json:"source_lid"`
}
)
func (c *client) handleVkusvillIntegration(
ctx context.Context,
request HandleVacancy,
vacancyExtraFields *VacancyExtraFields,
companyMetadata *CompanyMetadata,
companyExtraFieldsTemplate *CompanyExtraFieldsTemplate,
) error {
host, ok := (*companyMetadata)["host"]
if !ok {
return fmt.Errorf("host not found in metadata")
}
method, ok := (*companyMetadata)["method"]
if !ok {
return fmt.Errorf("request method not found in metadata")
}
lastName, name, secondName := splitName(request.Candidate.Name)
extraFields, err := parseVacancyExtraFields(vacancyExtraFields, companyExtraFieldsTemplate)
if err != nil {
return fmt.Errorf("error extracting vacancy id: %w", err)
}
vacancyId, ok := extraFields[vkusvillVacancyIdFieldKey].(int)
if !ok {
return fmt.Errorf("vacancy id field is not of expected type (int)")
}
sourceUrl, err := url.Parse(request.SourceLid)
if err != nil {
return fmt.Errorf("error parsing source lid: %w", err)
}
query := sourceUrl.Query()
// TODO: think what we potentially want (maybe request id for tracing etc.)
query.Set("candidate_id", request.Candidate.Id)
sourceUrl.RawQuery = query.Encode()
saveCandidateReq := vkusvillSaveCandidateRequest{
Status: 0,
LastName: lastName,
Name: name,
SecondName: secondName,
Birthday: formatDate(request.Candidate.Birthday, dmYFormat, YmdFormat),
Phone: []string{request.Candidate.Phone},
VacancyId: vacancyId,
CityJob: "",
MetroArea: "",
UtmSource: vkusvillMolvaUtmSource,
SourceLid: vkusvillSourceLid{
Url: sourceUrl.String(),
},
}
resp, err := c.vkusvillSaveCandidate(method, host, &saveCandidateReq)
if err != nil {
return fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
// TODO: think about following:
// у Вкусвилла ошибки возвращаются в теле ответа. Соответственно, нужно
// а) проверить наличие ошибки
// б) если ошибка есть, создать алерт об этом (например, в админку). Вообще надо подумать над этим сценарием
return c.vkusvillHandleResponse(resp)
}
type (
VkusvillSaveCandidateResponse struct {
//nolint:tagliatelle // used in vkusvill
Error bool `json:"ERR"`
//nolint:tagliatelle // used in vkusvill
Data json.RawMessage `json:"DATA"`
}
VkusvillErrorMessage struct {
Field string `json:"field"`
Message string `json:"message"`
}
VkusvillErrorDetails struct {
Data []VkusvillErrorMessage
}
VkusvillSuccessDetails struct {
// TODO: maybe add more fields
}
)
func (r *VkusvillSaveCandidateResponse) GetErrorDetails() (*VkusvillErrorDetails, error) {
var errDetails VkusvillErrorDetails
if err := json.Unmarshal(r.Data, &errDetails); err != nil {
return nil, err
}
return &errDetails, nil
}
func (r *VkusvillSaveCandidateResponse) GetSuccessDetails() (*VkusvillSuccessDetails, error) {
var successDetails VkusvillSuccessDetails
if err := json.Unmarshal(r.Data, &successDetails); err != nil {
return nil, err
}
return &successDetails, nil
}
func (c *client) vkusvillHandleResponse(resp *http.Response) error {
const handlerName = "integration.vkusvillHandleResponse"
var vkusvillResp VkusvillSaveCandidateResponse
if err := json.NewDecoder(resp.Body).Decode(&vkusvillResp); err != nil {
return fmt.Errorf("error decoding vkusvill response: %w", err)
}
if vkusvillResp.Error {
errDetails, err := vkusvillResp.GetErrorDetails()
if err != nil {
return fmt.Errorf("error getting vkusvill error details: %w", err)
}
return fmt.Errorf("error saving candidate in vkusvill HRM: %v", errDetails)
}
successDetails, err := vkusvillResp.GetSuccessDetails()
if err != nil {
return fmt.Errorf("error getting vkusvill success details: %w", err)
}
// TODO: remove after testing
c.logger.Debug("vkusvill response body",
slog.String("body", string(vkusvillResp.Data)),
slog.String("handler", handlerName))
c.logger.Debug("vkusvill success details",
slog.Any("success_details", successDetails),
slog.String("handler", handlerName))
return nil
}
func splitName(name string) (string, string, string) {
splittedName := strings.SplitN(name, " ", 3)
var (
lastName, firstName, secondName string
)
if len(splittedName) > 1 {
lastName = splittedName[0]
}
if len(splittedName) > 2 {
firstName = splittedName[1]
}
if len(splittedName) > 3 {
secondName = splittedName[2]
}
return lastName, firstName, secondName
}
func formatDate(date, fromFmt, toFmt string) string {
parsed, err := time.Parse(fromFmt, date)
if err != nil {
return date
}
return parsed.Format(toFmt)
}
//nolint:gocognit // TODO: refactor
func parseVacancyExtraFields(
vacancyExtraFields *VacancyExtraFields,
companyExtraFieldsTemplate *CompanyExtraFieldsTemplate,
) (map[string]any, error) {
resMap := make(map[string]any)
for _, vacXField := range vacancyExtraFields.Fields {
if _, ok := vkusvillNeededFields[vacXField.SystemName]; !ok {
continue
}
if len(vacXField.Value) == 0 {
// TODO: Maybe here we need to check whether corresponding field template
// has required flag set to true
continue
}
if vacXField.IsValidValue {
// get corresponding value from company template
for _, comFieldTemplate := range companyExtraFieldsTemplate.Fields {
if comFieldTemplate.SystemName != vacXField.SystemName {
continue
}
if vacXField.MultipleChoice {
var (
nameValMap = make(map[string]string, len(comFieldTemplate.ValidValues))
res = make([]any, len(vacXField.Value))
err error
)
for _, validVal := range comFieldTemplate.ValidValues {
nameValMap[validVal.DisplayName] = validVal.Value
}
for i, val := range vacXField.Value {
res[i], err = convertFieldtype(vacXField.Type, nameValMap[val])
if err != nil {
return nil, fmt.Errorf("error converting field value: %w", err)
}
}
resMap[vacXField.SystemName] = res
} else {
for _, validVal := range comFieldTemplate.ValidValues {
if validVal.DisplayName != vacXField.Value[0] {
continue
}
res, err := convertFieldtype(vacXField.Type, validVal.Value)
if err != nil {
return nil, fmt.Errorf("error converting field value: %w", err)
}
resMap[vacXField.SystemName] = res
}
}
}
continue
}
if vacXField.MultipleChoice {
var (
res = make([]any, len(vacXField.Value))
err error
)
for i, v := range vacXField.Value {
res[i], err = convertFieldtype(vacXField.Type, v)
if err != nil {
return nil, fmt.Errorf("error converting field value: %w", err)
}
}
resMap[vacXField.SystemName] = res
} else {
res, err := convertFieldtype(vacXField.Type, vacXField.Value[0])
if err != nil {
return nil, fmt.Errorf("error converting field value: %w", err)
}
resMap[vacXField.SystemName] = res
}
}
return resMap, nil
}
func VkusvillGetCandidateStatus(status int) (rmodel.SubmissionStatus, error) {
switch status {
case 0: // Статус не установлен
return rmodel.SubStatusUnspecified, nil
case 666: // Архивный
return rmodel.SubStatusCancelled, nil
case 49: // Без вакансии
return rmodel.SubStatusCancelled, nil
case 50: // На рассмотрении
return rmodel.SubStatusOnInterview, nil
case 51: // Отклоненный
return rmodel.SubStatusRejected, nil
case 115: // Зарезервированный
return rmodel.SubStatusApproved, nil
default:
return rmodel.SubStatusUnspecified, ErrUnknownCandidateStatus
}
}

View File

@@ -0,0 +1,36 @@
package integration
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
func (c *client) vkusvillSaveCandidate(method, url string, request *vkusvillSaveCandidateRequest) (*http.Response, error) {
secrets, ok := c.secrets[VkusvillCompanyName].(VkusvillSecretsConfig)
if !ok {
return nil, fmt.Errorf("vkusvill secrets are not of expected type")
}
body, err := json.Marshal(request)
if err != nil {
return nil, fmt.Errorf("error marshaling request: %w", err)
}
req, err := http.NewRequest(method, url, bytes.NewBuffer(body))
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Molva/1.0")
req.Header.Set("Authorization", secrets.ApiToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("error sending request: %w", err)
}
return resp, nil
}