blob: 7b0a2029774b86842cc57c81d71ed667dc9fb08e [file] [log] [blame]
/*
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 = "+1 800-789-7890"
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")
httpReq, err := http.NewRequest("GET", conn.getURL("HealthCheck"), nil)
httpReq.Header.Set("Authorization", conn.credentials)
// See if we get a response.
resp, err := conn.client.Do(httpReq)
if err != nil {
return fmt.Errorf("Health check failed to connect to server: %v", err)
} else if resp.StatusCode != 200 {
return fmt.Errorf("Health check returned unhealthy status: %s", resp.Status)
}
log.Printf("health check success! Got status: %s", resp.Status)
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) {
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(tB) == 0 {
log.Printf("ListBookings returning %d found bookings", len(gB))
return gB, nil
}
if len(gB) != len(tB) {
log.Printf("ListBookings number of bookings differed unexpectedly. Got: %d, Want: %d.", len(gB), len(tB))
}
sort.Sort(gB)
sort.Sort(tB)
var out Bookings
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])
}
log.Printf("ListBookings returning %d bookings", len(out))
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(bookingID string, conn *HTTPConnection) error {
reqPB := &mpb.UpdateBookingRequest{
Booking: &mpb.Booking{
BookingId: bookingID,
Status: mpb.BookingStatus_CANCELED,
},
}
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)
}
// New slot.
lastAvailability := slots[len(slots)-1]
reqPB := &mpb.UpdateBookingRequest{
Booking: &mpb.Booking{
BookingId: newBooking.GetBookingId(),
Slot: &mpb.Slot{
StartSec: lastAvailability.GetStartSec(),
DurationSec: lastAvailability.GetDurationSec(),
},
},
}
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)
}
// Update slot before performing diff.
newBooking.GetSlot().StartSec = lastAvailability.GetStartSec()
newBooking.GetSlot().DurationSec = lastAvailability.GetDurationSec()
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().GetBookingId(), 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)
}
// Price is difficult to verify without knowing aggregator specific taxes/fees. We only check that it is present.
if resp.GetFeesAndTaxes() == nil || cmp.Diff(fpb.Price{}, *resp.GetFeesAndTaxes(), cmp.Comparer(proto.Equal)) == "" {
return fmt.Errorf("invalid response. CheckOrderFulfillability.FeesAndTaxes must be populated. Offending response: %v", resp)
}
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{
Ids: &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()
var orderIDs mpb.ListOrdersRequest_OrderIds
for _, order := range orders {
orderIDs.OrderId = append(orderIDs.OrderId, order.GetOrderId())
}
reqPB.Ids = &mpb.ListOrdersRequest_OrderIds_{&orderIDs}
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
}