blob: 90c69ffdbac3163ee733a733d827c71dc4f36c75 [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 = "+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.
data, err := ioutil.ReadFile(credentialsFile)
if err != nil {
return nil, err
}
usernamePassword := 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: "Basic " + base64.StdEncoding.EncodeToString([]byte(usernamePassword)),
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) (*http.Response, 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, Request %v\n", rpcName, time.Now().UTC().Format(time.RFC850), httpReq)
httpResp, err := conn.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("invalid response. %s yielded error: %v", rpcName, err)
}
return httpResp, nil
}
// CheckAvailability beforms 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)
}
defer httpResp.Body.Close()
var resp mpb.CheckAvailabilityResponse
if err := jsonpb.Unmarshal(httpResp.Body, &resp); err != nil {
return fmt.Errorf("CheckAvailability: Could not parse HTTP response to pb3: %v", err)
}
log.Printf("CheckAvailability Response. Received(unix): %s, Response %s", time.Now().UTC().Format(time.RFC850), resp.String())
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)
}
defer httpResp.Body.Close()
var resp mpb.CreateBookingResponse
if err := jsonpb.Unmarshal(httpResp.Body, &resp); err != nil {
return nil, fmt.Errorf("CreateBooking: Could not parse HTTP response to pb3: %v", err)
}
log.Printf("CreateBooking Response. Received(unix): %s, Response %s", time.Now().UTC().Format(time.RFC850), resp.String())
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)
}
defer idemHTTPResp.Body.Close()
var idemResp mpb.CreateBookingResponse
if err := jsonpb.Unmarshal(httpResp.Body, &resp); err != nil {
return nil, fmt.Errorf("CreateBooking idem: Could not parse HTTP response to pb3: %v", err)
}
log.Printf("Idempotency check -- Response. Received(unix): %s, Response %s", time.Now().UTC().Format(time.RFC850), idemResp.String())
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)
}
defer httpResp.Body.Close()
var resp mpb.ListBookingsResponse
if err := jsonpb.Unmarshal(httpResp.Body, &resp); err != nil {
return nil, fmt.Errorf("ListBookings: Could not parse HTTP response to pb3: %v", err)
}
log.Printf("ListBookings Response. Received(unix): %s, Response %s", time.Now().UTC().Format(time.RFC850), resp.String())
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)
}
defer httpResp.Body.Close()
var resp mpb.GetBookingStatusResponse
if err := jsonpb.Unmarshal(httpResp.Body, &resp); err != nil {
return fmt.Errorf("GetBookingsStatus: Could not parse HTTP response to pb3: %v", err)
}
log.Printf("GetBookingStatus Response. Received(unix): %s, Response %s", time.Now().UTC().Format(time.RFC850), resp.String())
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)
}
defer httpResp.Body.Close()
var resp mpb.UpdateBookingResponse
if err := jsonpb.Unmarshal(httpResp.Body, &resp); err != nil {
return fmt.Errorf("CancelBooking: Could not parse HTTP response to pb3: %v", err)
}
log.Printf("UpdateBooking Response. Received(unix): %s, Response %s", time.Now().UTC().Format(time.RFC850), resp.String())
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)
defer updateHTTPResp.Body.Close()
var resp mpb.CreateBookingResponse
if err := jsonpb.Unmarshal(updateHTTPResp.Body, &resp); err != nil {
return fmt.Errorf("Rescheduling UpdateBooking: Could not parse HTTP response to pb3: %v", err)
}
log.Printf("UpdateBooking Response. Received(unix): %s, Response %s", time.Now().UTC().Format(time.RFC850), resp.String())
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)
}