From 31ecd429cae5a6558b532f114c559335d841009e Mon Sep 17 00:00:00 2001 From: Daniel Francesconi Date: Tue, 21 Jan 2025 16:47:09 +0100 Subject: [PATCH] Add guest requests validators --- v_2018_10/errors.go | 37 ++++ v_2018_10/types_read.go | 303 +++++++++++++++++++++++++ v_2018_10/validation.go | 14 ++ v_2018_10/validation_read.go | 418 +++++++++++++++++++++++++++++++++++ 4 files changed, 772 insertions(+) create mode 100644 v_2018_10/types_read.go create mode 100644 v_2018_10/validation_read.go diff --git a/v_2018_10/errors.go b/v_2018_10/errors.go index 846e617..854680e 100644 --- a/v_2018_10/errors.go +++ b/v_2018_10/errors.go @@ -17,6 +17,39 @@ var ( ErrMissingLongName = newMissingElementError("MultimediaDescription with attribute InfoCode = 25 (Long name)") ErrDuplicateLanguage = newError("duplicate language found for element Description") ErrMissingRoomID = newMissingAttributeError("RoomID") + ErrMissingID = newMissingAttributeError("UniqueID.ID") + ErrMissingRoomStay = newMissingElementError("RoomStay") + ErrMissingRoomTypeCode = newMissingAttributeError("RoomTypeCode") + ErrMissingRatePlanCode = newMissingAttributeError("RatePlanCode") + ErrInvalidPercent = newError("percent must be ≤ 100") + ErrMissingMealsIncluded = newMissingElementError("MealsIncluded") + ErrMissingGuestCount = newMissingElementError("GuestCount") + ErrDuplicateAdultGuestCount = newError("duplicate element GuestCount for adults") + ErrMissingStart = newMissingAttributeError("Start") + ErrMissingEnd = newMissingAttributeError("End") + ErrMissingTotal = newMissingElementError("Total") + ErrStartAfterEnd = newError("start must be ≤ end") + ErrMissingDuration = newMissingAttributeError("Duration") + ErrMissingStartDateWindow = newMissingElementError("StartDateWindow") + ErrEarliestDateAfterLatestDate = newError("earliest date must be ≤ latest date") + ErrDurationOutOfRange = newError("duration exceeds the allowed date range") + ErrInvalidNamePrefix = newError("invalid value for attribute NamePrefix") + ErrMissingGivenName = newMissingAttributeError("GivenName") + ErrMissingSurname = newMissingAttributeError("Surname") + ErrInvalidNameTitle = newError("invalid value for attribute NameTitle") + ErrInvalidAddressLine = newError("invalid value for attribute AddressLine") + ErrInvalidCityName = newError("invalid value for attribute CityName") + ErrInvalidPostalCode = newError("invalid value for attribute PostalCode") + ErrInvalidCountryNameCode = newError("invalid value for attribute CountryName.Code") + ErrInvalidListItem = newError("invalid value for element ListItem") + ErrInvalidCommentText = newError("invalid value for element Comment.Text") + ErrInvalidPenaltyDescriptionText = newError("invalid value for attribute element PenaltyDescription.Text") + ErrInvalidResIDValue = newError("invalid value for attribute ResIDValue") + ErrInvalidResIDSource = newError("invalid value for attribute ResIDSource") + ErrInvalidResIDSourceContext = newError("invalid value for attribute ResIDSourceContext") + ErrInvalidCompanyNameCode = newError("invalid value for attribute CompanyName.Code") + ErrInvalidCompanyNameValue = newError("invalid value for element CompanyName") + ErrInvalidEmail = newError("invalid value for element Email") ) func ErrInvalidBookingLimit(n int) *Error { @@ -47,6 +80,10 @@ func ErrInvalidPictureCategoryCode(code int) *Error { return newErrorf("invalid value for attribute Category %d", code) } +func ErrInvalidUniqueID(status string, uidType int) *Error { + return newErrorf("invalid value for attributes ResStatus %s and Type %d", status, uidType) +} + func newMissingAttributeError(attribute string) *Error { return newErrorf("missing required attribute %s", attribute) } diff --git a/v_2018_10/types_read.go b/v_2018_10/types_read.go new file mode 100644 index 0000000..39d79cc --- /dev/null +++ b/v_2018_10/types_read.go @@ -0,0 +1,303 @@ +package v_2018_10 + +import ( + "encoding/xml" + "fmt" + "regexp" + "strconv" + "time" + + "github.com/HGV/x/timex" +) + +type ReadRQ struct { + XMLName xml.Name `xml:"http://www.opentravel.org/OTA/2003/05 OTA_ReadRQ"` + Version string `xml:"Version,attr"` + HotelReadRequest HotelReadRequest `xml:"ReadRequests>HotelReadRequest"` +} + +type HotelReadRequest struct { + HotelCode string `xml:"HotelCode,attr"` + SelectionCriteria *SelectionCriteria `xml:"SelectionCriteria,omitempty"` +} + +type SelectionCriteria struct { + Start time.Time `xml:"Start,attr"` +} + +type ResRetrieveRS struct { + XMLName xml.Name `xml:"http://www.opentravel.org/OTA/2003/05 OTA_ResRetrieveRS"` + Version string `xml:"Version,attr"` + HotelReservations []HotelReservation `xml:"ReservationsList>HotelReservation"` +} + +type ResStatus string + +const ( + ResStatusRequested ResStatus = "Requested" + ResStatusReserved ResStatus = "Reserved" + ResStatusModify ResStatus = "Modify" + ResStatusCancelled ResStatus = "Cancelled" +) + +func (s ResStatus) IsReservation() bool { + return s == ResStatusReserved || s == ResStatusModify +} + +type UniqueIDType2 int + +const ( + UniqueIDType2Reservation UniqueIDType2 = 14 + UniqueIDType2Cancellation UniqueIDType2 = 15 +) + +// TODO: Move to another package or prefix name +type UniqueID2 struct { + Type UniqueIDType2 `xml:"Type,attr"` + ID string `xml:"ID,attr"` +} + +type HotelReservation struct { + CreateDateTime time.Time `xml:"CreateDateTime,attr"` + ResStatus ResStatus `xml:"ResStatus,attr"` + UniqueID UniqueID2 `xml:"UniqueID"` + RoomStays []RoomStay `xml:"RoomStays>RoomStay"` + Customer Customer `xml:"ResGuests>ResGuest>Profiles>ProfileInfo>Profile>Customer"` + ResGlobalInfo ResGlobalInfo `xml:"ResGlobalInfo"` +} + +type RoomStay struct { + RoomType ResRoomType `xml:"RoomTypes>RoomType"` + RatePlan ResRatePlan `xml:"RatePlans>RatePlan"` + GuestCounts []GuestCount `xml:"GuestCounts>GuestCount"` + TimeSpan TimeSpan `xml:"TimeSpan"` + Total *Total `xml:"Total"` +} + +type ResRoomType struct { + RoomTypeCode string `xml:"RoomTypeCode,attr,omitempty"` + RoomClassificationCode int `xml:"RoomClassificationCode,attr,omitempty"` + RoomType *int `xml:"RoomType,attr,omitempty"` +} + +type ResRatePlan struct { + RatePlanCode string `xml:"RatePlanCode,attr,omitempty"` + Commission *Commission `xml:"Commission"` + MealsIncluded *MealsIncluded `xml:"MealsIncluded"` +} + +type Commission struct { + Percent *int `xml:"Percent,attr"` + CommissionPayableAmount *CommissionPayableAmount `xml:"CommissionPayableAmount"` +} + +type CommissionPayableAmount struct { + Amount string `xml:"Amount,attr"` + CurrencyCode string `xml:"CurrencyCode,attr"` +} + +type MealsIncluded struct { + MealPlanIndicator bool `xml:"MealPlanIndicator,attr"` + // MealPlanCodes BoardType `xml:"MealPlanCodes,attr"` +} + +// TODO: BoardType + +type GuestCount struct { + Count int `xml:"Count,attr"` + Age *int `xml:"Age,attr"` +} + +type TimeSpan struct { + Start *timex.Date `xml:"Start,attr"` + End *timex.Date `xml:"End,attr"` + Duration *Duration `xml:"Duration,attr"` + StartDateWindow *StartDateWindow `xml:"StartDateWindow"` +} + +type Duration struct { + Nights int +} + +var regexpDuration = regexp.MustCompile(`^P(P[0-9]+)N$`) + +func ParseDuration(s string) (Duration, error) { + var d Duration + + if !regexpDuration.MatchString(s) { + return d, fmt.Errorf("invalid duration format: %s, expected format is 'PxN'", s) + } + + matches := regexpDuration.FindStringSubmatch(s) + for i, name := range regexpDuration.SubexpNames() { + switch match := matches[i]; name { + case "nights": + nights, err := strconv.Atoi(match) + if err != nil { + return d, err + } + d.Nights = nights + } + } + + return d, nil +} + +func (d *Duration) UnmarshalText(data []byte) error { + var err error + *d, err = ParseDuration(string(data)) + return err +} + +func (d Duration) MarshalText() ([]byte, error) { + return []byte(d.String()), nil +} + +func (d Duration) String() string { + return fmt.Sprintf("P%dN", d.Nights) +} + +type StartDateWindow struct { + EarliestDate timex.Date `xml:"EarliestDate,attr"` + LatestDate timex.Date `xml:"LatestDate,attr"` +} + +type Total struct { + AmountAfterTax string `xml:"AmountAfterTax,attr"` + CurrencyCode string `xml:"CurrencyCode,attr"` +} + +type Gender string + +const ( + GenderMale Gender = "Male" + GenderFemale Gender = "Female" + GenderUnknown Gender = "Unknown" +) + +type Customer struct { + Gender *Gender `xml:"Gender,attr"` + BirthDate *timex.Date `xml:"BirthDate,attr"` + Language string `xml:"Language,attr,omitempty"` + PersonName PersonName `xml:"PersonName"` + Phones []Phone `xml:"Telephone"` + Email *Email `xml:"Email"` + Address *Address `xml:"Address"` +} + +type PersonName struct { + NamePrefix *string `xml:"NamePrefix"` + GivenName string `xml:"GivenName"` + Surname string `xml:"Surname"` + NameTitle *string `xml:"NameTitle"` +} + +type PhoneTechType string + +const ( + PhoneTechTypeVoice PhoneTechType = "1" + PhoneTechTypeFax PhoneTechType = "3" + PhoneTechTypeMobile PhoneTechType = "5" +) + +type Phone struct { + PhoneTechType PhoneTechType `xml:"PhoneTechType,attr"` + PhoneNumber string `xml:"PhoneNumber,attr"` +} + +type Remark string + +const ( + RemarkNewsletterYes Remark = "newsletter:yes" + RemarkCatalogYes Remark = "catalog:yes" +) + +type Email struct { + // Type string `xml:"EmailType,attr,omitempty"` + Remark Remark `xml:"Remark,attr,omitempty"` + Value string `xml:",innerxml"` +} + +type Address struct { + Language string `xml:"Language,attr,omitempty"` + Remark Remark `xml:"Remark,attr,omitempty"` + AddressLine *string `xml:"AddressLine,omitempty"` + CityName *string `xml:"CityName,omitempty"` + PostalCode *string `xml:"PostalCode,omitempty"` + StateProv *StateProv `xml:"StateProv,omitempty"` + CountryName *CountryName `xml:"CountryName,omitempty"` +} + +type StateProv struct { + StateCode string `xml:"StateCode,attr"` +} + +type CountryName struct { + Code string `xml:"Code,attr"` +} + +type ResGlobalInfo struct { + Comments *[]Comment `xml:"Comments>Comment"` + SpecialRequests *[]SpecialRequest `xml:"SpecialRequests>SpecialRequest"` + CancelPenalty *string `xml:"CancelPenalties>CancelPenalty>PenaltyDescription>Text"` + HotelReservationID *HotelReservationID `xml:"HotelReservationIDs>HotelReservationIDs"` + Profile *Profile `xml:"Profiles>ProfileInfo>Profile"` + BasicPropertyInfo BasicPropertyInfo `xml:"BasicPropertyInfo"` +} + +type Comment struct { + Name string `xml:"Name,attr"` + ListItems []ListItem `xml:"ListItem,omitempty"` + Text *Text `xml:"Text,omitempty"` +} + +type ListItem struct { + ListItem int `xml:"ListItem,attr,omitempty"` + Language string `xml:"Language,attr,omitempty"` + Value string `xml:",innerxml"` +} + +type Text struct { + Value string `xml:",innerxml"` +} + +type SpecialRequest struct { + Name string `xml:"Name,attr"` + Text *Text `xml:"Text"` +} + +type HotelReservationID struct { + ResIDType int `xml:"ResID_Type,attr"` + ResIDValue *string `xml:"ResID_Value,attr"` + ResIDSource *string `xml:"ResID_Source,attr"` + ResIDSourceContext *string `xml:"ResID_SourceContext,attr"` +} + +type ProfileType int + +const ( + ProfileTypeTravelAgent = 4 +) + +type Profile struct { + ProfileType ProfileType `xml:"ProfileType,attr"` + CompanyInfo CompanyInfo `xml:"CompanyInfo"` +} + +type CompanyInfo struct { + CompanyName CompanyName `xml:"CompanyName"` + AddressInfo *Address `xml:"AddressInfo"` + TelephoneInfo *Phone `xml:"TelephoneInfo"` + Email *Email `xml:"Email"` +} + +type CompanyName struct { + Code string `xml:"Code,attr"` + CodeContext string `xml:"CodeContext,attr"` + Value string `xml:",innerxml"` +} + +type BasicPropertyInfo struct { + HotelCode string `xml:"HotelCode,attr"` + HotelName string `xml:"HotelName,attr,omitempty"` +} diff --git a/v_2018_10/validation.go b/v_2018_10/validation.go index ca46b40..6cc873d 100644 --- a/v_2018_10/validation.go +++ b/v_2018_10/validation.go @@ -49,3 +49,17 @@ func validateLanguageUniqueness(descs []Description) error { } return nil } + +func validateString(s string) error { + if strings.TrimSpace(s) == "" { + return errors.New("string is empty or contains only whitespace") + } + return nil +} + +func validateNonNilString(s *string) error { + if s == nil { + return nil + } + return validateString(*s) +} diff --git a/v_2018_10/validation_read.go b/v_2018_10/validation_read.go new file mode 100644 index 0000000..b878049 --- /dev/null +++ b/v_2018_10/validation_read.go @@ -0,0 +1,418 @@ +package v_2018_10 + +import ( + "net/mail" + "strings" +) + +type ReadValidator struct{} + +var _ Validatable[ReadRQ] = (*ReadValidator)(nil) + +func (v ReadValidator) Validate(r ReadRQ) error { + if err := validateHotelCode(r.HotelReadRequest.HotelCode); err != nil { + return err + } + return nil +} + +type ResRetrieveValidator struct { + roomTypeCodes map[string]struct{} + guestRequests []ResStatus // TODO: Naming +} + +var _ Validatable[ResRetrieveRS] = (*ResRetrieveValidator)(nil) + +type ResRetrieveValidatorFunc func(*ResRetrieveValidator) + +func NewResRetrieveValidator(opts ...ResRetrieveValidatorFunc) ResRetrieveValidator { + var v ResRetrieveValidator + for _, opt := range opts { + opt(&v) + } + return v +} + +func WithRoomTypeCodes(mapping map[string]struct{}) ResRetrieveValidatorFunc { + return func(v *ResRetrieveValidator) { + v.roomTypeCodes = mapping + } +} + +func (v ResRetrieveValidator) Validate(r ResRetrieveRS) error { + for _, res := range r.HotelReservations { + if err := v.validateHotelReservation(res); err != nil { + return err + } + } + return nil +} + +func (v ResRetrieveValidator) validateHotelReservation(h HotelReservation) error { + if err := v.validateUniqueID(h.UniqueID, h.ResStatus); err != nil { + return err + } + v.guestRequests = append(v.guestRequests, h.ResStatus) + + if err := v.validateRoomStays(h.RoomStays); err != nil { + return err + } + + if err := v.validateCustomer(h.Customer); err != nil { + return err + } + + if err := v.validateResGlobalInfo(h.ResGlobalInfo); err != nil { + return err + } + + return nil +} + +func (v ResRetrieveValidator) validateUniqueID(uid UniqueID2, resStatus ResStatus) error { + switch resStatus { + case ResStatusRequested, ResStatusReserved, ResStatusModify: + if uid.Type != UniqueIDType2Reservation { + return ErrInvalidUniqueID(string(resStatus), int(uid.Type)) + } + case ResStatusCancelled: + if uid.Type != UniqueIDType2Cancellation { + return ErrInvalidUniqueID(string(resStatus), int(uid.Type)) + } + } + + if strings.TrimSpace(uid.ID) == "" { + return ErrMissingID + } + + return nil +} + +func (v ResRetrieveValidator) validateRoomStays(roomStays []RoomStay) error { + if len(roomStays) == 0 { + return ErrMissingRoomStay + } + + for _, roomStay := range roomStays { + if err := v.validateRoomStay(roomStay); err != nil { + return err + } + + // validateAlternativeRoomStay... + } + + return nil +} + +func (v ResRetrieveValidator) validateRoomStay(roomStay RoomStay) error { + if err := v.validateRoomType(roomStay.RoomType); err != nil { + return err + } + + if err := v.validateRatePlan(roomStay.RatePlan); err != nil { + return err + } + + if err := v.validateGuestCounts(roomStay.GuestCounts); err != nil { + return err + } + + if err := v.validateTimeSpan(roomStay.TimeSpan); err != nil { + return err + } + + if err := v.validateTotal(roomStay.Total); err != nil { + return err + } + + return nil +} + +func (v ResRetrieveValidator) validateRoomType(roomType ResRoomType) error { + if strings.TrimSpace(roomType.RoomTypeCode) == "" { + return ErrMissingRoomTypeCode + } + + if v.roomTypeCodes != nil { // TODO: oder len()>0 + if _, ok := v.roomTypeCodes[roomType.RoomTypeCode]; !ok { + return ErrInvCodeNotFound(roomType.RoomTypeCode) + } + } + + return nil +} + +func (v ResRetrieveValidator) validateRatePlan(ratePlan ResRatePlan) error { + if strings.TrimSpace(ratePlan.RatePlanCode) == "" { + return ErrMissingRatePlanCode + } + + if c := ratePlan.Commission; c != nil { + if err := v.validateCommission(*c); err != nil { + return err + } + } + + if err := v.validateMealsIncluded(ratePlan.MealsIncluded); err != nil { + return err + } + + return nil +} + +func (v ResRetrieveValidator) validateCommission(commission Commission) error { + if commission.Percent != nil { + if *commission.Percent > 100 { + return ErrInvalidPercent + } + } + + return nil +} + +func (v ResRetrieveValidator) validateMealsIncluded(mealsIncluded *MealsIncluded) error { + if v.isReservation() && mealsIncluded == nil { + return ErrMissingMealsIncluded + } + return nil +} + +func (v ResRetrieveValidator) validateGuestCounts(guestCounts []GuestCount) error { + if len(guestCounts) == 0 { + return ErrMissingGuestCount + } + + adultSeen := false + for _, guestCount := range guestCounts { + if guestCount.Age == nil && adultSeen { + return ErrDuplicateAdultGuestCount + } + adultSeen = adultSeen || guestCount.Age == nil + } + + return nil +} + +func (v ResRetrieveValidator) validateTimeSpan(timeSpan TimeSpan) error { + if v.isReservation() { + if timeSpan.Start == nil { + return ErrMissingStart + } + if timeSpan.End == nil { + return ErrMissingEnd + } + if timeSpan.Start.After(*timeSpan.End) { + return ErrStartAfterEnd + } + } else { + if timeSpan.Duration == nil { + return ErrMissingDuration + } + if err := v.validateStartDateWindow(timeSpan.StartDateWindow, *timeSpan.Duration); err != nil { + return err + } + } + return nil +} + +func (v ResRetrieveValidator) validateStartDateWindow(w *StartDateWindow, d Duration) error { + if w == nil { + return ErrMissingStartDateWindow + } + + if w.EarliestDate.After(w.LatestDate) { + return ErrEarliestDateAfterLatestDate + } + + if d.Nights <= w.LatestDate.DaysSince(w.EarliestDate) { + return ErrDurationOutOfRange + } + + return nil +} + +func (v ResRetrieveValidator) validateTotal(total *Total) error { + if v.isReservation() && total == nil { + return ErrMissingTotal + } + return nil +} + +func (v ResRetrieveValidator) validateCustomer(customer Customer) error { + if err := v.validatePersonName(customer.PersonName); err != nil { + return err + } + + if customer.Email != nil { + if err := v.validateEmail(*customer.Email); err != nil { + return err + } + } + + if customer.Address != nil { + if err := v.validateAddress(*customer.Address); err != nil { + return err + } + } + + return nil +} + +func (v ResRetrieveValidator) validatePersonName(personName PersonName) error { + if personName.NamePrefix != nil && strings.TrimSpace(*personName.NamePrefix) == "" { + return ErrInvalidNamePrefix + } + + if strings.TrimSpace(personName.GivenName) == "" { + return ErrMissingGivenName + } + + if strings.TrimSpace(personName.Surname) == "" { + return ErrMissingSurname + } + + if personName.NameTitle != nil && strings.TrimSpace(*personName.NameTitle) == "" { + return ErrInvalidNameTitle + } + + return nil +} + +func (v ResRetrieveValidator) validateEmail(email Email) error { + _, err := mail.ParseAddress(email.Value) + return err +} + +func (v ResRetrieveValidator) validateAddress(address Address) error { + if err := validateNonNilString(address.AddressLine); err != nil { + return ErrInvalidAddressLine + } + + if err := validateNonNilString(address.CityName); err != nil { + return ErrInvalidCityName + } + + if err := validateNonNilString(address.PostalCode); err != nil { + return ErrInvalidPostalCode + } + + if err := v.validateCountryName(address.CountryName); err != nil { + return ErrInvalidCountryNameCode + } + + return nil +} + +func (v ResRetrieveValidator) validateCountryName(countryName *CountryName) error { + if countryName == nil { + return nil + } + return validateString(countryName.Code) +} + +func (v ResRetrieveValidator) validateResGlobalInfo(globalInfo ResGlobalInfo) error { + if err := v.validateComments(globalInfo.Comments); err != nil { + return err + } + + if v.isReservation() { + if err := validateNonNilString(globalInfo.CancelPenalty); err != nil { + return ErrInvalidPenaltyDescriptionText + } + } + + if err := v.validateHotelReservationID(globalInfo.HotelReservationID); err != nil { + return err + } + + if globalInfo.Profile != nil { + if err := v.validateCompanyInfo(globalInfo.Profile.CompanyInfo); err != nil { + return err + } + } + + if err := validateHotelCode(globalInfo.BasicPropertyInfo.HotelCode); err != nil { + return err + } + + return nil +} + +func (v ResRetrieveValidator) validateComments(comments *[]Comment) error { + if comments == nil { + return nil + } + + for _, comment := range *comments { + for _, listItem := range comment.ListItems { + if err := validateString(listItem.Value); err != nil { + return ErrInvalidListItem + } + } + if comment.Text != nil { + if err := validateString(comment.Text.Value); err != nil { + return ErrInvalidCommentText + } + } + } + + return nil +} + +func (v ResRetrieveValidator) validateHotelReservationID(id *HotelReservationID) error { + if id == nil { + return nil + } + + if err := validateNonNilString(id.ResIDValue); err != nil { + return ErrInvalidResIDValue + } + + if err := validateNonNilString(id.ResIDSource); err != nil { + return ErrInvalidResIDSource + } + + if err := validateNonNilString(id.ResIDSourceContext); err != nil { + return ErrInvalidResIDSourceContext + } + + return nil +} + +func (v ResRetrieveValidator) validateCompanyInfo(companyInfo CompanyInfo) error { + if err := v.validateCompanyName(companyInfo.CompanyName); err != nil { + return err + } + + if companyInfo.AddressInfo != nil { + if err := v.validateAddress(*companyInfo.AddressInfo); err != nil { + return err + } + } + + if companyInfo.Email != nil { + if err := v.validateEmail(*companyInfo.Email); err != nil { + return ErrInvalidEmail + } + } + + return nil +} + +func (v ResRetrieveValidator) validateCompanyName(companyName CompanyName) error { + if err := validateString(companyName.Code); err != nil { + return ErrInvalidCompanyNameCode + } + + if err := validateString(companyName.Value); err != nil { + return ErrInvalidCompanyNameValue + } + + return nil +} + +// Returns true if the current guest request being validated is a reservation. +func (v ResRetrieveValidator) isReservation() bool { + status := v.guestRequests[len(v.guestRequests)-1] + return status.IsReservation() +}