| /* |
| Copyright 2017 Google Inc. |
| |
| Licensed under the Apache License, Version 2.0 (the "License"); |
| you may not use this file except in compliance with the License. |
| You may obtain a copy of the License at |
| |
| https://www.apache.org/licenses/LICENSE-2.0 |
| |
| Unless required by applicable law or agreed to in writing, software |
| distributed under the License is distributed on an "AS IS" BASIS, |
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| See the License for the specific language governing permissions and |
| limitations under the License. |
| */ |
| |
| // Package api contains validation wrappers over BookingService endpoints. |
| package api |
| |
| import ( |
| "bytes" |
| "crypto/tls" |
| "crypto/x509" |
| "encoding/base64" |
| "errors" |
| "fmt" |
| "io/ioutil" |
| "log" |
| "math/rand" |
| "net/http" |
| "sort" |
| "strconv" |
| "strings" |
| "time" |
| |
| "github.com/golang/protobuf/jsonpb" |
| "github.com/golang/protobuf/proto" |
| "github.com/google/go-cmp/cmp" |
| "github.com/maps-booking-v3/utils" |
| |
| fpb "github.com/maps-booking-v3/feeds" |
| mpb "github.com/maps-booking-v3/v3" |
| ) |
| |
| const ( |
| userID = "0" |
| firstName = "Jane" |
| lastName = "Doe" |
| telephone = "+18007897890" |
| email = "test@example.com" |
| ) |
| |
| // HTTPConnection is a convenience struct for holding connection-related objects. |
| type HTTPConnection struct { |
| client *http.Client |
| credentials string |
| marshaler *jsonpb.Marshaler |
| baseURL string |
| } |
| |
| func setupCertConfig(caFile string, fullServerName string) (*tls.Config, error) { |
| if caFile == "" { |
| return nil, nil |
| } |
| b, err := ioutil.ReadFile(caFile) |
| if err != nil { |
| return nil, fmt.Errorf("failed to read root certificates file: %v", err) |
| } |
| cp := x509.NewCertPool() |
| if !cp.AppendCertsFromPEM(b) { |
| return nil, errors.New("failed to parse root certificates, please check your roots file (ca_file flag) and try again") |
| } |
| return &tls.Config{ |
| RootCAs: cp, |
| ServerName: fullServerName, |
| }, nil |
| } |
| |
| // InitHTTPConnection creates and returns a new HTTPConnection object |
| // with a given server address and username/password. |
| func InitHTTPConnection(serverAddr string, credentialsFile string, caFile string, fullServerName string) (*HTTPConnection, error) { |
| // Set up username/password. |
| var credentials string |
| if credentialsFile != "" { |
| data, err := ioutil.ReadFile(credentialsFile) |
| if err != nil { |
| return nil, err |
| } |
| credentials = "Basic " + base64.StdEncoding.EncodeToString([]byte(strings.Replace(string(data), "\n", "", -1))) |
| } |
| config, err := setupCertConfig(caFile, fullServerName) |
| if err != nil { |
| return nil, err |
| } |
| protocol := "http" |
| if config != nil { |
| protocol = "https" |
| } |
| return &HTTPConnection{ |
| client: &http.Client{ |
| Timeout: 10 * time.Second, |
| Transport: &http.Transport{TLSClientConfig: config}, |
| }, |
| credentials: credentials, |
| marshaler: &jsonpb.Marshaler{OrigName: true}, |
| baseURL: protocol + "://" + serverAddr, |
| }, nil |
| } |
| |
| func (h HTTPConnection) getURL(rpcName string) string { |
| if rpcName != "" { |
| return h.baseURL + "/v3/" + rpcName |
| } |
| return h.baseURL |
| } |
| |
| // Bookings is a convenience type for a booking array. |
| type Bookings []*mpb.Booking |
| |
| func (b Bookings) Len() int { |
| return len(b) |
| } |
| |
| func (b Bookings) Less(i, j int) bool { |
| return b[i].GetBookingId() < b[j].GetBookingId() |
| } |
| |
| func (b Bookings) Swap(i, j int) { |
| b[i], b[j] = b[j], b[i] |
| } |
| |
| // HealthCheck performs a health check. |
| func HealthCheck(conn *HTTPConnection) error { |
| utils.LogFlow("Health Check", "Start") |
| defer utils.LogFlow("Health Check", "End") |
| |
| // See if we get a response. |
| _, err := conn.client.Get(conn.getURL("")) |
| if err != nil { |
| return fmt.Errorf("could not complete health check: %v", err) |
| } |
| log.Println("health check success!") |
| return nil |
| } |
| |
| // sendRequest sets up and sends the relevant HTTP request to the server and returns the HTTP response. |
| func sendRequest(rpcName string, req string, conn *HTTPConnection) (string, error) { |
| httpReq, err := http.NewRequest("POST", conn.getURL(rpcName), bytes.NewBuffer([]byte(req))) |
| httpReq.Header.Set("Content-Type", "application/json") |
| httpReq.Header.Set("Authorization", conn.credentials) |
| log.Printf("%v Request. Sent(unix): %s, Url: %v, Method: %v, Header: %v, Body: %v\n", rpcName, time.Now().UTC().Format(time.RFC850), httpReq.URL, httpReq.Method, httpReq.Header, httpReq.Body) |
| |
| httpResp, err := conn.client.Do(httpReq) |
| if err != nil { |
| return "", fmt.Errorf("invalid response. %s yielded error: %v", rpcName, err) |
| } |
| defer httpResp.Body.Close() |
| bodyBytes, err := ioutil.ReadAll(httpResp.Body) |
| if err != nil { |
| return "", fmt.Errorf("Could not read http response body") |
| } |
| bodyString := string(bodyBytes) |
| log.Printf("%v Response. Received(unix): %s, Response %v\n", rpcName, time.Now().UTC().Format(time.RFC850), bodyString) |
| return bodyString, nil |
| } |
| |
| // CheckAvailability performs a maps booking availability check on all supplied availability slots. This function |
| // will return all slots with a valid return. |
| func CheckAvailability(a *fpb.Availability, conn *HTTPConnection) error { |
| slot, err := utils.BuildSlotFrom(a) |
| if err != nil { |
| return fmt.Errorf("unable to build request for check availability flow. err: %v, availability record: %v", err, a.String()) |
| } |
| reqPB := &mpb.CheckAvailabilityRequest{ |
| Slot: slot, |
| } |
| req, err := conn.marshaler.MarshalToString(reqPB) |
| if err != nil { |
| return fmt.Errorf("Could not convert pb3 to json: %v", reqPB) |
| } |
| httpResp, err := sendRequest("CheckAvailability", req, conn) |
| if err != nil { |
| return fmt.Errorf("invalid response. CheckAvailability yielded error: %v", err) |
| } |
| var resp mpb.CheckAvailabilityResponse |
| if err := jsonpb.UnmarshalString(httpResp, &resp); err != nil { |
| return fmt.Errorf("CheckAvailability: Could not parse HTTP response to pb3: %v", err) |
| } |
| |
| if diff := cmp.Diff(resp.GetSlot(), slot, cmp.Comparer(proto.Equal)); diff != "" { |
| return fmt.Errorf("invalid response. CheckAvailability slots differ (-got +want)\n%s", diff) |
| } |
| |
| if resp.GetCountAvailable() == 0 { |
| return errors.New("no count available in response") |
| } |
| |
| return nil |
| } |
| |
| // CreateBooking attempts to create bookings from availability slots. |
| func CreateBooking(a *fpb.Availability, conn *HTTPConnection) (*mpb.Booking, error) { |
| slot, err := utils.BuildSlotFrom(a) |
| if err != nil { |
| return nil, fmt.Errorf("unable to build request for check availability flow. err: %v, availability record: %v", err, a.String()) |
| } |
| |
| gen := rand.New(rand.NewSource(time.Now().UnixNano())) |
| // Lease currently unsupported. |
| reqPB := &mpb.CreateBookingRequest{ |
| Slot: slot, |
| UserInformation: &mpb.UserInformation{ |
| UserId: userID, |
| GivenName: firstName, |
| FamilyName: lastName, |
| Telephone: telephone, |
| Email: email, |
| }, |
| PaymentInformation: &mpb.PaymentInformation{ |
| PrepaymentStatus: mpb.PrepaymentStatus_PREPAYMENT_NOT_PROVIDED, |
| }, |
| IdempotencyToken: strconv.Itoa(gen.Intn(1000000)), |
| } |
| req, err := conn.marshaler.MarshalToString(reqPB) |
| if err != nil { |
| return nil, fmt.Errorf("Could not convert pb3 to json: %v", reqPB) |
| } |
| |
| httpResp, err := sendRequest("CreateBooking", req, conn) |
| if err != nil { |
| return nil, fmt.Errorf("invalid response. CreateBooking yielded error: %v", err) |
| } |
| var resp mpb.CreateBookingResponse |
| if err := jsonpb.UnmarshalString(httpResp, &resp); err != nil { |
| return nil, fmt.Errorf("CreateBooking: Could not parse HTTP response to pb3: %v", err) |
| } |
| |
| if resp.GetBookingFailure() != nil { |
| return nil, fmt.Errorf("invalid response. CreateBooking failed with booking failure %v", resp.GetBookingFailure()) |
| } |
| |
| b := resp.GetBooking() |
| if iE := utils.ValidateBooking(b, &mpb.Booking{ |
| Slot: reqPB.GetSlot(), |
| UserInformation: reqPB.GetUserInformation(), |
| PaymentInformation: reqPB.GetPaymentInformation(), |
| }); iE != nil { |
| return nil, fmt.Errorf("invalid response. CreateBooking invalid: %s", iE.Error()) |
| } |
| |
| // Perform idempotency test. |
| log.Printf("Idempotency check -- CreateBooking Request. Sent(unix): %s, Request %s", time.Now().UTC().Format(time.RFC850), reqPB.String()) |
| idemHTTPResp, err := sendRequest("CreateBooking", req, conn) |
| if err != nil { |
| return nil, fmt.Errorf("invalid response. Idempotency check yielded error: %v", err) |
| } |
| var idemResp mpb.CreateBookingResponse |
| if err := jsonpb.UnmarshalString(idemHTTPResp, &idemResp); err != nil { |
| return nil, fmt.Errorf("CreateBooking idem: Could not parse HTTP response to pb3: %v", err) |
| } |
| |
| if diff := cmp.Diff(idemResp, resp); diff != "" { |
| return b, fmt.Errorf("Idempotency check invalid (-got +want)\n%s", diff) |
| } |
| |
| return b, nil |
| } |
| |
| // ListBookings calls the maps booking ListBookings rpc and compares the return with all input bookings. |
| func ListBookings(tB Bookings, conn *HTTPConnection) (Bookings, error) { |
| var out Bookings |
| reqPB := &mpb.ListBookingsRequest{ |
| UserId: userID, |
| } |
| req, err := conn.marshaler.MarshalToString(reqPB) |
| if err != nil { |
| return nil, fmt.Errorf("Could not convert pb3 to json: %v", reqPB) |
| } |
| httpResp, err := sendRequest("ListBookings", req, conn) |
| if err != nil { |
| return nil, fmt.Errorf("invalid response. ListBookings yielded error: %v. Abandoning all booking from this flow", err) |
| } |
| var resp mpb.ListBookingsResponse |
| if err := jsonpb.UnmarshalString(httpResp, &resp); err != nil { |
| return nil, fmt.Errorf("ListBookings: Could not parse HTTP response to pb3: %v", err) |
| } |
| |
| gB := Bookings(resp.GetBookings()) |
| if len(gB) != len(tB) { |
| return out, fmt.Errorf("number of bookings differ, ListBookings invalid. Got: %d, Want: %d. Abandoning all bookings from this flow", len(gB), len(tB)) |
| } |
| |
| sort.Sort(gB) |
| sort.Sort(tB) |
| for i := 0; i < len(tB); i++ { |
| if iE := utils.ValidateBooking(gB[i], tB[i]); iE != nil { |
| log.Printf("ListBookings invalid, %s, abandoning slot %d/%d", iE.Error(), i, len(tB)) |
| continue |
| } |
| out = append(out, tB[i]) |
| } |
| |
| return out, nil |
| } |
| |
| // GetBookingStatus checks that all input bookings are in an acceptable state. |
| func GetBookingStatus(b *mpb.Booking, conn *HTTPConnection) error { |
| reqPB := &mpb.GetBookingStatusRequest{ |
| BookingId: b.GetBookingId(), |
| } |
| |
| req, err := conn.marshaler.MarshalToString(reqPB) |
| if err != nil { |
| return fmt.Errorf("Could not convert pb3 to json: %v", reqPB) |
| } |
| httpResp, err := sendRequest("GetBookingStatus", req, conn) |
| if err != nil { |
| return fmt.Errorf("invalid response. GetBookingStatus yielded error: %v", err) |
| } |
| var resp mpb.GetBookingStatusResponse |
| if err := jsonpb.UnmarshalString(httpResp, &resp); err != nil { |
| return fmt.Errorf("GetBookingsStatus: Could not parse HTTP response to pb3: %v", err) |
| } |
| |
| if diff := cmp.Diff(resp.GetBookingStatus(), mpb.BookingStatus_CONFIRMED); diff != "" { |
| return fmt.Errorf("invalid response. BookingStatus differ (-got +want)\n%s", diff) |
| } |
| |
| return nil |
| } |
| |
| // CancelBooking is a clean up method that cancels all supplied bookings. |
| func CancelBooking(b *mpb.Booking, conn *HTTPConnection) error { |
| b.Status = mpb.BookingStatus_CANCELED |
| reqPB := &mpb.UpdateBookingRequest{ |
| Booking: b, |
| } |
| req, err := conn.marshaler.MarshalToString(reqPB) |
| if err != nil { |
| return fmt.Errorf("Could not convert pb3 to json: %v", reqPB) |
| } |
| httpResp, err := sendRequest("UpdateBooking", req, conn) |
| if err != nil { |
| return fmt.Errorf("invalid response. UpdateBooking yielded error: %v", err) |
| } |
| var resp mpb.UpdateBookingResponse |
| if err := jsonpb.UnmarshalString(httpResp, &resp); err != nil { |
| return fmt.Errorf("CancelBooking: Could not parse HTTP response to pb3: %v", err) |
| } |
| |
| if iE := utils.ValidateBooking(resp.GetBooking(), reqPB.GetBooking()); iE != nil { |
| return fmt.Errorf("invalid response. UpdateBooking: %s", iE.Error()) |
| } |
| return nil |
| } |
| |
| // Rescheduling will attempt to create a booking, update the booking, then cancel. |
| func Rescheduling(av []*fpb.Availability, conn *HTTPConnection) error { |
| var slots []*fpb.Availability |
| for _, v := range utils.BuildMerchantServiceMap(av) { |
| // Need at least two slots for reschedule. |
| if len(v) <= 1 { |
| continue |
| } |
| slots = v |
| break |
| } |
| |
| if len(slots) == 0 { |
| return errors.New("no suitable availability for rescheduling flow. exiting") |
| } |
| // Book first slot. |
| newBooking, err := CreateBooking(slots[0], conn) |
| if err != nil { |
| return fmt.Errorf("could not complete booking, abandoning rescheduling flow: %v", err) |
| } |
| slot := newBooking.GetSlot() |
| |
| // New slot. |
| lastAvailability := slots[len(slots)-1] |
| slot.StartSec = lastAvailability.GetStartSec() |
| slot.DurationSec = lastAvailability.GetDurationSec() |
| |
| reqPB := &mpb.UpdateBookingRequest{ |
| Booking: newBooking, |
| } |
| req, err := conn.marshaler.MarshalToString(reqPB) |
| if err != nil { |
| return fmt.Errorf("Rescheduling UpdateBooking: Could not convert pb3 to json: %v", reqPB) |
| } |
| updateHTTPResp, err := sendRequest("UpdateBooking", req, conn) |
| var resp mpb.CreateBookingResponse |
| if err := jsonpb.UnmarshalString(updateHTTPResp, &resp); err != nil { |
| return fmt.Errorf("Rescheduling UpdateBooking: Could not parse HTTP response to pb3: %v", err) |
| } |
| |
| if iE := utils.ValidateBooking(resp.GetBooking(), newBooking); iE != nil { |
| return fmt.Errorf("invalid response. UpdateBooking: %s, abandoning slot 1/1", iE.Error()) |
| } |
| return CancelBooking(resp.GetBooking(), conn) |
| } |
| |
| // CheckOrderFulfillability attempts to send a CheckOrderFulfillabilityRequest |
| // to the connection endpoint and diff the results with what are expected. |
| func CheckOrderFulfillability(merchantID string, lineItems []*mpb.LineItem, conn *HTTPConnection) error { |
| reqPB := &mpb.CheckOrderFulfillabilityRequest{ |
| MerchantId: merchantID, |
| Item: lineItems, |
| } |
| req, err := conn.marshaler.MarshalToString(reqPB) |
| if err != nil { |
| return fmt.Errorf("Could not convert pb3 to json: %v", reqPB) |
| } |
| httpResp, err := sendRequest("CheckOrderFulfillability", req, conn) |
| if err != nil { |
| return fmt.Errorf("invalid response. CheckOrderFulfillability yielded error: %v", err) |
| } |
| var resp mpb.CheckOrderFulfillabilityResponse |
| if err := jsonpb.UnmarshalString(httpResp, &resp); err != nil { |
| return fmt.Errorf("CheckOrderFulfillability: Could not parse HTTP response to pb3: %v", err) |
| } |
| |
| // We ignore price for now. This is difficult to verify without knowing aggregator specific taxes/fees. |
| orderFulfillability := resp.GetFulfillability() |
| // TODO(ccawdrey): Add validation cases for other OrderFulFillability enums. |
| if diff := cmp.Diff(orderFulfillability.GetResult(), mpb.OrderFulfillability_CAN_FULFILL); diff != "" { |
| return fmt.Errorf("invalid response. CheckOrderFulfillability.Fulfillability.OrderFulfillabilityResult differ (-got +want)\n%s", diff) |
| } |
| if orderFulfillability.GetUnfulfillableReason() != "" { |
| return errors.New("invalid response. CheckOrderFulfillability.UnfulfillableReason should be empty") |
| } |
| |
| var respLineItems []*mpb.LineItem |
| for _, lineItemFulfillability := range orderFulfillability.GetItemFulfillability() { |
| if diff := cmp.Diff(lineItemFulfillability.GetResult(), mpb.LineItemFulfillability_CAN_FULFILL); diff != "" { |
| return fmt.Errorf("invalid response. CheckOrderFulfillability.Fulfillability.ItemFulfillability.Result for LineItem %v -- differ (-got +want)\n%s", lineItemFulfillability.GetItem(), diff) |
| } |
| if lineItemFulfillability.GetUnfulfillableReason() != "" { |
| return errors.New("invalid response. CheckOrderFulfillability.Fulfillability.ItemFulfillability.UnfulfillableReason should be empty") |
| } |
| |
| respLineItems = append(respLineItems, lineItemFulfillability.GetItem()) |
| } |
| |
| if err = utils.ValidateLineItems(respLineItems, lineItems, false); err != nil { |
| return fmt.Errorf("invalid response. CheckOrderFulfillability %v", err) |
| } |
| |
| return nil |
| } |
| |
| // CreateOrder will attempt to build an order from a merchant id and array of line orders. |
| func CreateOrder(merchantID string, lineItems []*mpb.LineItem, conn *HTTPConnection) (*mpb.Order, error) { |
| gen := rand.New(rand.NewSource(time.Now().UnixNano())) |
| |
| reqOrder := &mpb.Order{ |
| UserInformation: &mpb.UserInformation{ |
| UserId: userID, |
| GivenName: firstName, |
| FamilyName: lastName, |
| Telephone: telephone, |
| Email: email, |
| }, |
| PaymentInformation: &mpb.PaymentInformation{ |
| PrepaymentStatus: mpb.PrepaymentStatus_PREPAYMENT_NOT_PROVIDED, |
| }, |
| MerchantId: merchantID, |
| Item: lineItems, |
| } |
| reqPB := &mpb.CreateOrderRequest{ |
| Order: reqOrder, |
| IdempotencyToken: strconv.Itoa(gen.Intn(1000000)), |
| } |
| |
| req, err := conn.marshaler.MarshalToString(reqPB) |
| if err != nil { |
| return nil, fmt.Errorf("Could not convert pb3 to json: %v", reqPB) |
| } |
| httpResp, err := sendRequest("CreateOrder", req, conn) |
| if err != nil { |
| return nil, fmt.Errorf("invalid response. CreateOrder yielded error: %v", err) |
| } |
| var resp mpb.CreateOrderResponse |
| if err := jsonpb.UnmarshalString(httpResp, &resp); err != nil { |
| return nil, fmt.Errorf("CreateOrder: Could not parse HTTP response to pb3: %v", err) |
| } |
| |
| if resp.GetOrderFailure() != nil { |
| return nil, fmt.Errorf("invalid response. CreateOrder contains OrderFailure for request %v", reqPB) |
| } |
| |
| if err = utils.ValidateOrder(*resp.GetOrder(), *reqOrder); err != nil { |
| return nil, fmt.Errorf("invalid response. CreateOrder %v", err) |
| } |
| |
| // Perform idempotency test. |
| log.Printf("Idempotency check") |
| idemHTTPResp, err := sendRequest("CreateOrder", req, conn) |
| if err != nil { |
| return nil, fmt.Errorf("invalid response. Idempotency check yielded error: %v", err) |
| } |
| var idemResp mpb.CreateOrderResponse |
| if err := jsonpb.UnmarshalString(idemHTTPResp, &idemResp); err != nil { |
| return nil, fmt.Errorf("CreateOrder idem: Could not parse HTTP response to pb3: %v", err) |
| } |
| |
| if idemResp.GetOrderFailure() != nil { |
| return nil, errors.New("Idempotency check invalid. CreateOrder contains OrderFailure") |
| } |
| if err = utils.ValidateOrder(*idemResp.GetOrder(), *resp.GetOrder()); err != nil { |
| return nil, fmt.Errorf("Idempotency check invalid %v", err) |
| } |
| |
| return resp.GetOrder(), nil |
| } |
| |
| func sendListOrdersRequest(reqPB *mpb.ListOrdersRequest, conn *HTTPConnection) (mpb.ListOrdersResponse, error) { |
| req, err := conn.marshaler.MarshalToString(reqPB) |
| if err != nil { |
| return mpb.ListOrdersResponse{}, fmt.Errorf("Could not convert pb3 to json: %v", reqPB) |
| } |
| httpResp, err := sendRequest("ListOrders", req, conn) |
| if err != nil { |
| return mpb.ListOrdersResponse{}, fmt.Errorf("invalid response. ListOrders yielded error: %v", err) |
| } |
| var resp mpb.ListOrdersResponse |
| if err := jsonpb.UnmarshalString(httpResp, &resp); err != nil { |
| return resp, fmt.Errorf("ListOrders: Could not parse HTTP response to pb3: %v", err) |
| } |
| |
| return resp, nil |
| } |
| |
| // ListOrders first checks that the number and contents of the server's order state |
| // are consistent with what the test client assumes is present. |
| func ListOrders(orders []*mpb.Order, conn *HTTPConnection) error { |
| if len(orders) == 0 { |
| return errors.New("at least one order must be present for ListOrders to succeed") |
| } |
| |
| // UserId check first. |
| reqPB := &mpb.ListOrdersRequest{ |
| UserId: userID, |
| } |
| respUser, err := sendListOrdersRequest(reqPB, conn) |
| if err != nil { |
| return err |
| } |
| if err = utils.ValidateOrders(respUser.GetOrder(), orders); err != nil { |
| return fmt.Errorf("invalid response. ListOrders %v for request %v", err, reqPB) |
| } |
| |
| // Still here? OrderId check. |
| reqPB.Reset() |
| for _, order := range orders { |
| reqPB.OrderId = append(reqPB.OrderId, order.GetOrderId()) |
| } |
| respOrder, err := sendListOrdersRequest(reqPB, conn) |
| if err != nil { |
| return err |
| } |
| if err = utils.ValidateOrders(respOrder.GetOrder(), orders); err != nil { |
| return fmt.Errorf("invalid response. ListOrders %v for request %v", err, reqPB) |
| } |
| |
| return nil |
| } |