package travel import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "sync" "time" ) type AmadeusClient struct { apiKey string apiSecret string baseURL string httpClient *http.Client accessToken string tokenExpiry time.Time tokenMu sync.RWMutex } type AmadeusConfig struct { APIKey string APISecret string BaseURL string } func NewAmadeusClient(cfg AmadeusConfig) *AmadeusClient { baseURL := cfg.BaseURL if baseURL == "" { baseURL = "https://test.api.amadeus.com" } return &AmadeusClient{ apiKey: cfg.APIKey, apiSecret: cfg.APISecret, baseURL: baseURL, httpClient: &http.Client{ Timeout: 30 * time.Second, }, } } func (c *AmadeusClient) getAccessToken(ctx context.Context) (string, error) { c.tokenMu.RLock() if c.accessToken != "" && time.Now().Before(c.tokenExpiry) { token := c.accessToken c.tokenMu.RUnlock() return token, nil } c.tokenMu.RUnlock() c.tokenMu.Lock() defer c.tokenMu.Unlock() if c.accessToken != "" && time.Now().Before(c.tokenExpiry) { return c.accessToken, nil } data := url.Values{} data.Set("grant_type", "client_credentials") data.Set("client_id", c.apiKey) data.Set("client_secret", c.apiSecret) req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/security/oauth2/token", bytes.NewBufferString(data.Encode())) if err != nil { return "", fmt.Errorf("create token request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := c.httpClient.Do(req) if err != nil { return "", fmt.Errorf("token request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("token request failed: %s - %s", resp.Status, string(body)) } var tokenResp struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` } if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { return "", fmt.Errorf("decode token response: %w", err) } c.accessToken = tokenResp.AccessToken c.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn-60) * time.Second) return c.accessToken, nil } func (c *AmadeusClient) doRequest(ctx context.Context, method, path string, query url.Values, body interface{}) ([]byte, error) { token, err := c.getAccessToken(ctx) if err != nil { return nil, err } fullURL := c.baseURL + path if len(query) > 0 { fullURL += "?" + query.Encode() } var reqBody io.Reader if body != nil { jsonBody, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("marshal request body: %w", err) } reqBody = bytes.NewReader(jsonBody) } req, err := http.NewRequestWithContext(ctx, method, fullURL, reqBody) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("execute request: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode >= 400 { return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody)) } return respBody, nil } func (c *AmadeusClient) SearchFlights(ctx context.Context, req FlightSearchRequest) ([]FlightOffer, error) { query := url.Values{} query.Set("originLocationCode", req.Origin) query.Set("destinationLocationCode", req.Destination) query.Set("departureDate", req.DepartureDate) query.Set("adults", fmt.Sprintf("%d", req.Adults)) if req.ReturnDate != "" { query.Set("returnDate", req.ReturnDate) } if req.Children > 0 { query.Set("children", fmt.Sprintf("%d", req.Children)) } if req.CabinClass != "" { query.Set("travelClass", req.CabinClass) } if req.MaxPrice > 0 { query.Set("maxPrice", fmt.Sprintf("%d", req.MaxPrice)) } if req.Currency != "" { query.Set("currencyCode", req.Currency) } query.Set("max", "10") body, err := c.doRequest(ctx, "GET", "/v2/shopping/flight-offers", query, nil) if err != nil { return nil, err } var response struct { Data []amadeusFlightOffer `json:"data"` } if err := json.Unmarshal(body, &response); err != nil { return nil, fmt.Errorf("unmarshal flights: %w", err) } offers := make([]FlightOffer, 0, len(response.Data)) for _, data := range response.Data { offer := c.convertFlightOffer(data) offers = append(offers, offer) } return offers, nil } func (c *AmadeusClient) SearchHotels(ctx context.Context, req HotelSearchRequest) ([]HotelOffer, error) { query := url.Values{} query.Set("cityCode", req.CityCode) query.Set("checkInDate", req.CheckIn) query.Set("checkOutDate", req.CheckOut) query.Set("adults", fmt.Sprintf("%d", req.Adults)) if req.Rooms > 0 { query.Set("roomQuantity", fmt.Sprintf("%d", req.Rooms)) } if req.Currency != "" { query.Set("currency", req.Currency) } if req.Rating > 0 { query.Set("ratings", fmt.Sprintf("%d", req.Rating)) } query.Set("bestRateOnly", "true") hotelListBody, err := c.doRequest(ctx, "GET", "/v1/reference-data/locations/hotels/by-city", query, nil) if err != nil { return nil, fmt.Errorf("fetch hotel list: %w", err) } var hotelListResp struct { Data []struct { HotelID string `json:"hotelId"` Name string `json:"name"` GeoCode struct { Lat float64 `json:"latitude"` Lng float64 `json:"longitude"` } `json:"geoCode"` Address struct { CountryCode string `json:"countryCode"` } `json:"address"` } `json:"data"` } if err := json.Unmarshal(hotelListBody, &hotelListResp); err != nil { return nil, fmt.Errorf("unmarshal hotel list: %w", err) } offers := make([]HotelOffer, 0) for i, h := range hotelListResp.Data { if i >= 10 { break } offer := HotelOffer{ ID: h.HotelID, Name: h.Name, Lat: h.GeoCode.Lat, Lng: h.GeoCode.Lng, Currency: req.Currency, CheckIn: req.CheckIn, CheckOut: req.CheckOut, } offers = append(offers, offer) } return offers, nil } func (c *AmadeusClient) GetAirportByCode(ctx context.Context, code string) (*GeoLocation, error) { query := url.Values{} query.Set("subType", "AIRPORT") query.Set("keyword", code) body, err := c.doRequest(ctx, "GET", "/v1/reference-data/locations", query, nil) if err != nil { return nil, err } var response struct { Data []struct { IATACode string `json:"iataCode"` Name string `json:"name"` GeoCode struct { Lat float64 `json:"latitude"` Lng float64 `json:"longitude"` } `json:"geoCode"` Address struct { CountryName string `json:"countryName"` } `json:"address"` } `json:"data"` } if err := json.Unmarshal(body, &response); err != nil { return nil, err } if len(response.Data) == 0 { return nil, fmt.Errorf("airport not found: %s", code) } loc := response.Data[0] return &GeoLocation{ Lat: loc.GeoCode.Lat, Lng: loc.GeoCode.Lng, Name: loc.Name, Country: loc.Address.CountryName, }, nil } type amadeusFlightOffer struct { ID string `json:"id"` Itineraries []struct { Duration string `json:"duration"` Segments []struct { Departure struct { IATACode string `json:"iataCode"` At string `json:"at"` } `json:"departure"` Arrival struct { IATACode string `json:"iataCode"` At string `json:"at"` } `json:"arrival"` CarrierCode string `json:"carrierCode"` Number string `json:"number"` Duration string `json:"duration"` } `json:"segments"` } `json:"itineraries"` Price struct { Total string `json:"total"` Currency string `json:"currency"` } `json:"price"` TravelerPricings []struct { FareDetailsBySegment []struct { Cabin string `json:"cabin"` } `json:"fareDetailsBySegment"` } `json:"travelerPricings"` NumberOfBookableSeats int `json:"numberOfBookableSeats"` } func (c *AmadeusClient) convertFlightOffer(data amadeusFlightOffer) FlightOffer { offer := FlightOffer{ ID: data.ID, SeatsAvailable: data.NumberOfBookableSeats, } if len(data.Itineraries) > 0 && len(data.Itineraries[0].Segments) > 0 { itin := data.Itineraries[0] firstSeg := itin.Segments[0] lastSeg := itin.Segments[len(itin.Segments)-1] offer.DepartureAirport = firstSeg.Departure.IATACode offer.DepartureTime = firstSeg.Departure.At offer.ArrivalAirport = lastSeg.Arrival.IATACode offer.ArrivalTime = lastSeg.Arrival.At offer.Airline = firstSeg.CarrierCode offer.FlightNumber = firstSeg.CarrierCode + firstSeg.Number offer.Stops = len(itin.Segments) - 1 offer.Duration = parseDuration(itin.Duration) } if price, err := parseFloat(data.Price.Total); err == nil { offer.Price = price } offer.Currency = data.Price.Currency if len(data.TravelerPricings) > 0 && len(data.TravelerPricings[0].FareDetailsBySegment) > 0 { offer.CabinClass = data.TravelerPricings[0].FareDetailsBySegment[0].Cabin } return offer } func parseDuration(d string) int { var hours, minutes int fmt.Sscanf(d, "PT%dH%dM", &hours, &minutes) return hours*60 + minutes } func parseFloat(s string) (float64, error) { var f float64 _, err := fmt.Sscanf(s, "%f", &f) return f, err }