schmorrison / Zoho

Golang API for Zoho services
MIT License
35 stars 34 forks source link

Zoho Bookings API (v1) Integration #28

Closed ysahil97 closed 3 years ago

ysahil97 commented 3 years ago

Hi Sam

As discussed, I have written some API's for Zoho Bookings API which we currently want to use. The api's implemented here are related to appointments, staffs, resources, services and workspaces. Please let me know if there are any doubts or concerns in this pull request.

schmorrison commented 3 years ago

Here is my view of things:


package zoho

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "io/ioutil"
    "mime/multipart"
    "net/http"
    "net/url"
    "os"
    "path/filepath"
    "reflect"
    "github.com/schmorrison/go-querystring/query"  // small change from 'github.com/google/go-querystring/query'
)

// Endpoint defines the data required to interact with most Zoho REST api endpoints
type Endpoint struct {
    Method        HTTPMethod
    URL           string
    Name          string
    ResponseData  interface{}
    RequestBody   interface{}
    URLParameters map[string]Parameter
    Headers       map[string]string
    BodyFormat    BodyFormat
    Attachment    string
}

// Parameter is used to provide URL Parameters to zoho endpoints
type Parameter string
type BodyFormat string

const (
    JSON        = ""
    JSON_STRING = "jsonString"
    FILE        = "file"
    URL         = "url" // Added new BodyFormat option
)

// HTTPRequest is the function which actually performs the request to a Zoho endpoint as specified by the provided endpoint
func (z *Zoho) HTTPRequest(endpoint *Endpoint) (err error) {
    if reflect.TypeOf(endpoint.ResponseData).Kind() != reflect.Ptr {
        return fmt.Errorf("Failed, you must pass a pointer in the ResponseData field of endpoint")
    }

    // Load and renew access token if expired
    err = z.CheckForSavedTokens()
    if err == ErrTokenExpired {
        err := z.RefreshTokenRequest()
        if err != nil {
            return fmt.Errorf("Failed to refresh the access token: %s: %s", endpoint.Name, err)
        }
    }

    // Retrieve URL parameters
    endpointURL := endpoint.URL
    q := url.Values{}
    for k, v := range endpoint.URLParameters {
        if v != "" {
            q.Set(k, string(v))
        }
    }

    var (
        req         *http.Request
        reqBody     io.Reader
        contentType string
    )

    // Has a body, likely a CRUD operation (still possibly JSONString)
    if endpoint.BodyFormat == JSON || endpoint.BodyFormat == JSON_STRING {
        if endpoint.RequestBody != nil {
            // JSON Marshal the body
            marshalledBody, err := json.Marshal(endpoint.RequestBody)
            if err != nil {
                return fmt.Errorf("Failed to create json from request body")
            }

            reqBody = bytes.NewReader(marshalledBody)
            contentType = "application/x-www-form-urlencoded; charset=UTF-8"
        }
    }

    if endpoint.BodyFormat == JSON_STRING || endpoint.BodyFormat == FILE {
        // Create a multipart form
        var b bytes.Buffer
        w := multipart.NewWriter(&b)

        switch endpoint.BodyFormat {
        case JSON_STRING:
            // Use the form to create the proper field
            fw, err := w.CreateFormField("JSONString")
            if err != nil {
                return err
            }
            // Copy the request body JSON into the field
            if _, err = io.Copy(fw, reqBody); err != nil {
                return err
            }

            // Close the multipart writer to set the terminating boundary
            err = w.Close()
            if err != nil {
                return err
            }

        case FILE:
            // Retreive the file contents
            fileReader, err := os.Open(endpoint.Attachment)
            if err != nil {
                return err
            }
            defer fileReader.Close()
            // Create the correct form field
            part, err := w.CreateFormFile("attachment", filepath.Base(endpoint.Attachment))
            if err != nil {
                return err
            }
            // copy the file contents to the form
            if _, err = io.Copy(part, fileReader); err != nil {
                return err
            }

            err = w.Close()
            if err != nil {
                return err
            }
        }

        reqBody = &b
        contentType = w.FormDataContentType()
    }

       // New BodyFormat encoding option
    if endpoint.BodyFormat == URL {
        body, err := query.Values(endpoint.RequestBody) // send struct into the newly imported package
        if err != nil {
            return err
        }

        reqBody = strings.NewReader(body.Encode()) // write to body
        contentType = "application/x-www-form-urlencoded; charset=UTF-8"
    }

      /// NOTHING CHANGED PAST THIS POINT

    req, err = http.NewRequest(string(endpoint.Method), fmt.Sprintf("%s?%s", endpointURL, q.Encode()), reqBody)
    if err != nil {
        return fmt.Errorf("Failed to create a request for %s: %s", endpoint.Name, err)
    }

    req.Header.Set("Content-Type", contentType)

    // Add global authorization header
    req.Header.Add("Authorization", "Zoho-oauthtoken "+z.oauth.token.AccessToken)

    // Add specific endpoint headers
    for k, v := range endpoint.Headers {
        req.Header.Add(k, v)
    }

    resp, err := z.client.Do(req)
    if err != nil {
        return fmt.Errorf("Failed to perform request for %s: %s", endpoint.Name, err)
    }

    defer resp.Body.Close()

    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return fmt.Errorf("Failed to read body of response for %s: got status %s: %s", endpoint.Name, resolveStatus(resp), err)
    }

    dataType := reflect.TypeOf(endpoint.ResponseData).Elem()
    data := reflect.New(dataType).Interface()

    err = json.Unmarshal(body, data)
    if err != nil {
        return fmt.Errorf("Failed to unmarshal data from response for %s: got status %s: %s", endpoint.Name, resolveStatus(resp), err)
    }

    endpoint.ResponseData = data

    return nil
}

..............................................................................

Please review this and let me know what you think. Test it out to make sure it works with your project and endpoints.

Thanks again for the PR!

schmorrison commented 3 years ago

Forgot to show how to use the url encoding on the struct.

type CustomerDetails struct {
    Name string `json:"name"`
    Email string `json:"email"`
    PhoneNumber string `json:"phone_number"`
}

// Note the change to tag `url` instead of `json`
type BookAppointmentData struct {
    ServiceId string `url:"service_id"`
    StaffId string `url:"staff_id,omitempty"`
    ResourceId string `url:"resource_id,omitempty"`
    FromTime string `url:"from_time"`
    TimeZone string `url:"time_zone,omitempty"`
    Customer_Details CustomerDetails `url:"customer_details,json,omitempty"` // Note the option `json` before `omitempty`, the order shouldn't matter
}

// The struct as the given request body
func (c *API) BookAppointment(request BookAppointmentData) (data AppointmentResponse, err error) {
    endpoint := zoho.Endpoint{
        Name:         BookAppointmentModule,
        URL:          fmt.Sprintf("https://www.zohoapis.%s/bookings/v1/json/%s",c.ZohoTLD,BookAppointmentModule),
        Method:       zoho.HTTPPost,
        ResponseData: &AppointmentResponse{},
        RequestBody: request,
                BodyFormat: zoho.URL, // Use the URL BodyFormat
    }

    err = c.Zoho.HTTPRequest(&endpoint)
    if err != nil {
        return AppointmentResponse{}, fmt.Errorf("Failed to book appointment: %s", err)
    }
    if v, ok := endpoint.ResponseData.(*AppointmentResponse); ok {

        return *v, nil
    }
    return AppointmentResponse{}, fmt.Errorf("Data retrieved was not 'AppointmentResponse'")
}
ysahil97 commented 3 years ago

Hi Sam

The above suggested workaround to accommodate Zoho Booking requests seems to be good for me. I have tested with all the post API's present in Zoho Bookings API, and they seem to work fine.

I have an additional point to make towards one of the API's, namely BookAppointment api. In this API, we also need to enter some more personal information but customer_details only provides fields for name, email and phone number. As a workaround, Zoho Bookings has created a custom field API (link) which would solve this problem. The issue with incorporating custom fields in our PR is that, because of their nature of the fields, these are created separately for each project case and therefore there can't be any standardized library implementation of it. For our use case, we are planning to maintain a fork of your library and we would be using that fork as the library for our use cases. I will also add an README note for the same.

Could we finalize on your changes and bring closure to this PR? PS: In your fork of go-querystring, the go.mod file needs to be changed to reflect proper owner name.

Best Regards Sahil Yerawar

On Sun, Jul 25, 2021 at 12:11 AM Sam Morrison @.***> wrote:

Forgot to show how to use the url encoding on the struct.

type CustomerDetails struct { Name string json:"name" Email string json:"email" PhoneNumber string json:"phone_number" }

// Note the change to tag url instead of json type BookAppointmentData struct { ServiceId string url:"service_id" StaffId string url:"staff_id,omitempty" ResourceId string url:"resource_id,omitempty" FromTime string url:"from_time" TimeZone string url:"time_zone,omitempty" Customer_Details CustomerDetails url:"customer_details,json,omitempty" // Note the option json before omitempty, the order shouldn't matter }

// The struct as the given request body func (c *API) BookAppointment(request BookAppointmentData) (data AppointmentResponse, err error) { endpoint := zoho.Endpoint{ Name: BookAppointmentModule, URL: fmt.Sprintf("https://www.zohoapis.%s/bookings/v1/json/%s",c.ZohoTLD,BookAppointmentModule), Method: zoho.HTTPPost, ResponseData: &AppointmentResponse{}, RequestBody: request, BodyFormat: zoho.URL, // Use the URL BodyFormat }

err = c.Zoho.HTTPRequest(&endpoint) if err != nil { return AppointmentResponse{}, fmt.Errorf("Failed to book appointment: %s", err) } if v, ok := endpoint.ResponseData.(*AppointmentResponse); ok {

  return *v, nil

} return AppointmentResponse{}, fmt.Errorf("Data retrieved was not 'AppointmentResponse'") }

— You are receiving this because you authored the thread. Reply to this email directly, view it on GitHub https://github.com/schmorrison/Zoho/pull/28#issuecomment-886095062, or unsubscribe https://github.com/notifications/unsubscribe-auth/AEWHICSPCSILPKZ445ZCTB3TZMCPFANCNFSM476O4GFA .

-- Thanking You Sahil Yerawar

ysahil97 commented 3 years ago

Sorry, I forgot to add the link to the custom field API documentation: https://www.zoho.com/bookings/help/api/v1/book-appointment.html

On Mon, Jul 26, 2021 at 4:11 PM Sahil Yerawar @.***> wrote:

Hi Sam

The above suggested workaround to accommodate Zoho Booking requests seems to be good for me. I have tested with all the post API's present in Zoho Bookings API, and they seem to work fine.

I have an additional point to make towards one of the API's, namely BookAppointment api. In this API, we also need to enter some more personal information but customer_details only provides fields for name, email and phone number. As a workaround, Zoho Bookings has created a custom field API (link) which would solve this problem. The issue with incorporating custom fields in our PR is that, because of their nature of the fields, these are created separately for each project case and therefore there can't be any standardized library implementation of it. For our use case, we are planning to maintain a fork of your library and we would be using that fork as the library for our use cases. I will also add an README note for the same.

Could we finalize on your changes and bring closure to this PR? PS: In your fork of go-querystring, the go.mod file needs to be changed to reflect proper owner name.

Best Regards Sahil Yerawar

On Sun, Jul 25, 2021 at 12:11 AM Sam Morrison @.***> wrote:

Forgot to show how to use the url encoding on the struct.

type CustomerDetails struct { Name string json:"name" Email string json:"email" PhoneNumber string json:"phone_number" }

// Note the change to tag url instead of json type BookAppointmentData struct { ServiceId string url:"service_id" StaffId string url:"staff_id,omitempty" ResourceId string url:"resource_id,omitempty" FromTime string url:"from_time" TimeZone string url:"time_zone,omitempty" Customer_Details CustomerDetails url:"customer_details,json,omitempty" // Note the option json before omitempty, the order shouldn't matter }

// The struct as the given request body func (c *API) BookAppointment(request BookAppointmentData) (data AppointmentResponse, err error) { endpoint := zoho.Endpoint{ Name: BookAppointmentModule, URL: fmt.Sprintf("https://www.zohoapis.%s/bookings/v1/json/%s",c.ZohoTLD,BookAppointmentModule), Method: zoho.HTTPPost, ResponseData: &AppointmentResponse{}, RequestBody: request, BodyFormat: zoho.URL, // Use the URL BodyFormat }

err = c.Zoho.HTTPRequest(&endpoint) if err != nil { return AppointmentResponse{}, fmt.Errorf("Failed to book appointment: %s", err) } if v, ok := endpoint.ResponseData.(*AppointmentResponse); ok {

 return *v, nil

} return AppointmentResponse{}, fmt.Errorf("Data retrieved was not 'AppointmentResponse'") }

— You are receiving this because you authored the thread. Reply to this email directly, view it on GitHub https://github.com/schmorrison/Zoho/pull/28#issuecomment-886095062, or unsubscribe https://github.com/notifications/unsubscribe-auth/AEWHICSPCSILPKZ445ZCTB3TZMCPFANCNFSM476O4GFA .

-- Thanking You Sahil Yerawar

-- Thanking You Sahil Yerawar

schmorrison commented 3 years ago

I can't review/merge right now (ill try during this week), but take a look at this with regard to custom fields, struct embedding is almost exactly that. Its been on the roadmap forever.

https://play.golang.org/p/pH4iB-6VJ-a

It does require a bit of looking at, most Zoho responses come wrapped in something, for bookings you get {"response":{"returnValue": {......}, logMessage: [], status: ""}} and we would really want to do the embedding at the returnValue level. There are a couple ways to reconfigure the structs to allow it, but right now most structs in most packages are just faithful conversions from JSON.

If you create an issue about this we can decide the best way in a separate thread.

ysahil97 commented 3 years ago

Your welcome! Could you please merge the changes in the main branch?