package travel import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) type TwoGISClient struct { apiKey string baseURL string httpClient *http.Client } type TwoGISConfig struct { APIKey string BaseURL string } func (c *TwoGISClient) HasAPIKey() bool { return c.apiKey != "" } func NewTwoGISClient(cfg TwoGISConfig) *TwoGISClient { baseURL := cfg.BaseURL if baseURL == "" { baseURL = "https://catalog.api.2gis.com" } return &TwoGISClient{ apiKey: cfg.APIKey, baseURL: baseURL, httpClient: &http.Client{ Timeout: 15 * time.Second, }, } } type twoGISResponse struct { Meta struct { Code int `json:"code"` } `json:"meta"` Result struct { Items []twoGISItem `json:"items"` Total int `json:"total"` } `json:"result"` } type twoGISItem struct { ID string `json:"id"` Name string `json:"name"` FullName string `json:"full_name"` AddressName string `json:"address_name"` Type string `json:"type"` Point *struct { Lat float64 `json:"lat"` Lon float64 `json:"lon"` } `json:"point"` Address *struct { Components []struct { Type string `json:"type"` Name string `json:"name"` Country string `json:"country,omitempty"` } `json:"components,omitempty"` } `json:"address,omitempty"` PurposeName string `json:"purpose_name,omitempty"` Reviews *twoGISReviews `json:"reviews,omitempty"` Schedule map[string]twoGISScheduleDay `json:"schedule,omitempty"` } type twoGISReviews struct { GeneralRating float64 `json:"general_rating"` GeneralReviewCount int `json:"general_review_count"` OrgRating float64 `json:"org_rating"` OrgReviewCount int `json:"org_review_count"` } type twoGISScheduleDay struct { WorkingHours []struct { From string `json:"from"` To string `json:"to"` } `json:"working_hours"` } func (c *TwoGISClient) doRequest(ctx context.Context, endpoint string, params url.Values) (*twoGISResponse, error) { params.Set("key", c.apiKey) fullURL := c.baseURL + endpoint + "?" + params.Encode() req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("execute request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode >= 400 { return nil, fmt.Errorf("2GIS API error %d: %s", resp.StatusCode, string(body)) } var result twoGISResponse if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("unmarshal response: %w", err) } if result.Meta.Code >= 400 { return nil, fmt.Errorf("2GIS API meta error %d: %s", result.Meta.Code, string(body)) } return &result, nil } func (c *TwoGISClient) Geocode(ctx context.Context, query string) (*GeoLocation, error) { params := url.Values{} params.Set("q", query) params.Set("fields", "items.point") params.Set("type", "building,street,adm_div,adm_div.city,adm_div.place,adm_div.settlement,crossroad,attraction") result, err := c.doRequest(ctx, "/3.0/items/geocode", params) if err != nil { return nil, err } if len(result.Result.Items) == 0 { return nil, fmt.Errorf("location not found: %s", query) } item := result.Result.Items[0] if item.Point == nil { return nil, fmt.Errorf("no coordinates for: %s", query) } country := "" if item.Address != nil { for _, comp := range item.Address.Components { if comp.Type == "country" { country = comp.Name break } } } name := item.Name if name == "" { name = item.FullName } return &GeoLocation{ Lat: item.Point.Lat, Lng: item.Point.Lon, Name: name, Country: country, }, nil } func (c *TwoGISClient) ReverseGeocode(ctx context.Context, lat, lng float64) (*GeoLocation, error) { params := url.Values{} params.Set("lat", fmt.Sprintf("%f", lat)) params.Set("lon", fmt.Sprintf("%f", lng)) params.Set("fields", "items.point") result, err := c.doRequest(ctx, "/3.0/items/geocode", params) if err != nil { return nil, err } if len(result.Result.Items) == 0 { return nil, fmt.Errorf("location not found at %f,%f", lat, lng) } item := result.Result.Items[0] country := "" if item.Address != nil { for _, comp := range item.Address.Components { if comp.Type == "country" { country = comp.Name break } } } name := item.Name if name == "" { name = item.FullName } return &GeoLocation{ Lat: lat, Lng: lng, Name: name, Country: country, }, nil } type TwoGISPlace struct { ID string `json:"id"` Name string `json:"name"` Address string `json:"address"` Lat float64 `json:"lat"` Lng float64 `json:"lng"` Type string `json:"type"` Purpose string `json:"purpose"` Rating float64 `json:"rating"` ReviewCount int `json:"reviewCount"` Schedule map[string]string `json:"schedule,omitempty"` } func (c *TwoGISClient) SearchPlaces(ctx context.Context, query string, lat, lng float64, radius int) ([]TwoGISPlace, error) { params := url.Values{} params.Set("q", query) params.Set("point", fmt.Sprintf("%f,%f", lng, lat)) params.Set("radius", fmt.Sprintf("%d", radius)) params.Set("fields", "items.point,items.address,items.reviews,items.schedule") params.Set("page_size", "10") result, err := c.doRequest(ctx, "/3.0/items", params) if err != nil { return nil, err } places := make([]TwoGISPlace, 0, len(result.Result.Items)) for _, item := range result.Result.Items { if item.Point == nil { continue } addr := item.AddressName if addr == "" { addr = item.FullName } place := TwoGISPlace{ ID: item.ID, Name: item.Name, Address: addr, Lat: item.Point.Lat, Lng: item.Point.Lon, Type: item.Type, Purpose: item.PurposeName, } if item.Reviews != nil { place.Rating = item.Reviews.GeneralRating place.ReviewCount = item.Reviews.GeneralReviewCount if place.Rating == 0 { place.Rating = item.Reviews.OrgRating } if place.ReviewCount == 0 { place.ReviewCount = item.Reviews.OrgReviewCount } } if item.Schedule != nil { place.Schedule = formatSchedule(item.Schedule) } places = append(places, place) } return places, nil } func formatSchedule(sched map[string]twoGISScheduleDay) map[string]string { dayOrder := []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} dayRu := map[string]string{ "Mon": "Пн", "Tue": "Вт", "Wed": "Ср", "Thu": "Чт", "Fri": "Пт", "Sat": "Сб", "Sun": "Вс", } result := make(map[string]string, len(sched)) for _, d := range dayOrder { if day, ok := sched[d]; ok && len(day.WorkingHours) > 0 { wh := day.WorkingHours[0] result[dayRu[d]] = wh.From + "–" + wh.To } } return result } const twoGISRoutingBaseURL = "https://routing.api.2gis.com" type twoGISRoutingRequest struct { Points []twoGISRoutePoint `json:"points"` Transport string `json:"transport"` Output string `json:"output"` Locale string `json:"locale"` } type twoGISRoutePoint struct { Type string `json:"type"` Lon float64 `json:"lon"` Lat float64 `json:"lat"` } type twoGISRoutingResponse struct { Message string `json:"message"` Result []twoGISRoutingResult `json:"result"` } type twoGISRoutingResult struct { ID string `json:"id"` Algorithm string `json:"algorithm"` TotalDistance int `json:"total_distance"` TotalDuration int `json:"total_duration"` Maneuvers []twoGISManeuver `json:"maneuvers"` } type twoGISManeuver struct { ID string `json:"id"` Comment string `json:"comment"` Type string `json:"type"` OutcomingPath *twoGISOutcomingPath `json:"outcoming_path,omitempty"` } type twoGISOutcomingPath struct { Distance int `json:"distance"` Duration int `json:"duration"` Geometry []twoGISPathGeometry `json:"geometry"` } type twoGISPathGeometry struct { Selection string `json:"selection"` Length int `json:"length"` } func (c *TwoGISClient) GetRoute(ctx context.Context, points []GeoLocation, transport string) (*RouteDirection, error) { if len(points) < 2 { return nil, fmt.Errorf("at least 2 points required for routing") } if transport == "" { transport = "driving" } routePoints := make([]twoGISRoutePoint, len(points)) for i, p := range points { routePoints[i] = twoGISRoutePoint{ Type: "stop", Lon: p.Lng, Lat: p.Lat, } } reqBody := twoGISRoutingRequest{ Points: routePoints, Transport: transport, Output: "detailed", Locale: "ru", } jsonBody, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("marshal routing request: %w", err) } reqURL := fmt.Sprintf("%s/routing/7.0.0/global?key=%s", twoGISRoutingBaseURL, url.QueryEscape(c.apiKey)) req, err := http.NewRequestWithContext(ctx, "POST", reqURL, bytes.NewReader(jsonBody)) if err != nil { return nil, fmt.Errorf("create routing request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("execute routing request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read routing response: %w", err) } if resp.StatusCode >= 400 { return nil, fmt.Errorf("2GIS Routing API error %d: %s", resp.StatusCode, string(body)) } var routingResp twoGISRoutingResponse if err := json.Unmarshal(body, &routingResp); err != nil { return nil, fmt.Errorf("unmarshal routing response: %w", err) } if routingResp.Message != "" && routingResp.Message != "OK" && len(routingResp.Result) == 0 { return nil, fmt.Errorf("2GIS Routing error: %s", routingResp.Message) } if len(routingResp.Result) == 0 { return nil, fmt.Errorf("no route found") } route := routingResp.Result[0] var allCoords [][2]float64 var steps []RouteStep for _, m := range route.Maneuvers { if m.OutcomingPath != nil { for _, geom := range m.OutcomingPath.Geometry { coords := parseWKTLineString(geom.Selection) allCoords = append(allCoords, coords...) } if m.Comment != "" { steps = append(steps, RouteStep{ Instruction: m.Comment, Distance: float64(m.OutcomingPath.Distance), Duration: float64(m.OutcomingPath.Duration), Type: m.Type, }) } } } return &RouteDirection{ Geometry: RouteGeometry{ Coordinates: allCoords, Type: "LineString", }, Distance: float64(route.TotalDistance), Duration: float64(route.TotalDuration), Steps: steps, }, nil } func parseWKTLineString(wkt string) [][2]float64 { wkt = strings.TrimSpace(wkt) if !strings.HasPrefix(wkt, "LINESTRING(") { return nil } inner := wkt[len("LINESTRING(") : len(wkt)-1] pairs := strings.Split(inner, ",") coords := make([][2]float64, 0, len(pairs)) for _, pair := range pairs { pair = strings.TrimSpace(pair) parts := strings.Fields(pair) if len(parts) != 2 { continue } lon, err1 := strconv.ParseFloat(parts[0], 64) lat, err2 := strconv.ParseFloat(parts[1], 64) if err1 != nil || err2 != nil { continue } coords = append(coords, [2]float64{lon, lat}) } return coords }