Skip to content

Commit

Permalink
Update to reflect changes in Whereby's API
Browse files Browse the repository at this point in the history
Also includes some cleanup:
- Remove the WithContext methods, using context in all methods by default.
- Add more idiomatic error handling
- Add functional tests
  • Loading branch information
marcusirgens committed Dec 6, 2021
1 parent b0c02ce commit e3ece49
Show file tree
Hide file tree
Showing 16 changed files with 303 additions and 121 deletions.
31 changes: 24 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,35 @@ go get github.com/iterate/whereby-api-go
package main

import (
"context"
"fmt"
"log"
"os"
"time"

"github.com/iterate/whereby-api-go"
)

func main() {
wb := whereby.NewClient("my-api-key")
meeting, err := wb.CreateMeeting(whereby.CreateMeetingInput{
Start: time.Now(),
End: time.Now().Add(time.Hour),
})

fmt.Println(meeting.URL)
wb := whereby.NewClient(os.Getenv("WHEREBY_API_KEY"))

meeting, err := wb.CreateMeeting(context.Background(), whereby.CreateMeetingInput{
End: time.Now().Add(time.Hour),
})

if err != nil {
log.Printf("Something went wrong: %v\n", err)
}

fmt.Println(meeting.URL)
}
```

## Contributing
You may run a simple functional test by passing the `functional` tag to go test and setting the `-whereby-api-key` flag:

```shell
go test ./... -tags functional -whereby-api-key key
```

## Legal
Expand All @@ -37,3 +53,4 @@ Copyright (c) 2020 Mindcare AS.
Developed by [Iterate](https://iterate.no).

Licensed under the [MIT license](LICENSE.txt).

8 changes: 8 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package whereby

import (
"context"
"net/http"
"testing"
"time"
Expand All @@ -25,3 +26,10 @@ type mockClient struct {
func (m *mockClient) Do(req *http.Request) (*http.Response, error) {
return m.DoFunc(req)
}

func testContext(t *testing.T) (context.Context, context.CancelFunc) {
if ddl, ok := t.Deadline(); ok {
return context.WithDeadline(context.Background(), ddl)
}
return context.WithCancel(context.Background())
}
83 changes: 44 additions & 39 deletions create_meeting.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,89 +11,92 @@ import (
"time"
)

type createMeetingInInt struct {
type createMeetingPayload struct {
IsLocked bool `json:"isLocked"`
RoomNamePrefix string `json:"roomNamePrefix,omitempty"`
RoomNamePattern string `json:"roomNamePattern,omitempty"`
RoomMode string `json:"roomMode,omitempty"`
Start string `json:"startDate"`
Start string `json:"startDate,omitempty"`
End string `json:"endDate"`
Fields []string `json:"fields,omitempty"`
}

type CreateMeetingOutput = GetMeetingOutput

// CreateMeeting creates a meeting as specified. It will also create a transient
// room that is guaranteed to be available for specified start and end time.
// Some time after the meeting has ended, the transient room will be
// automatically deleted. The URL to this room is present in the response.
//
// See https://whereby.dev/http-api/#/paths/~1meetings/post for more details.
func (c *Client) CreateMeeting(input CreateMeetingInput) (*GetMeetingOutput, error) {
return c.CreateMeetingWithContext(context.Background(), input)
}

// CreateMeetingWithContext is the same as CreateMeeting with a user-specified
// context.
func (c *Client) CreateMeetingWithContext(ctx context.Context, input CreateMeetingInput) (*GetMeetingOutput, error) {
func (c *Client) CreateMeeting(ctx context.Context, input CreateMeetingInput) (CreateMeetingOutput, error) {
var out CreateMeetingOutput
if err := validateCreateMeetingInput(input); err != nil {
return nil, err
return out, err
}

payload, err := json.Marshal(c.getCreateMeetingInput(input))
if err != nil {
return nil, fmt.Errorf("failed to encode request body: %w", err)
return out, fmt.Errorf("failed to encode request body: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, createMeetingEndpoint, bytes.NewBuffer(payload))
if err != nil {
return nil, fmt.Errorf("failed create request: %w", err)
return out, fmt.Errorf("failed create request: %w", err)
}

req.Header.Set("content-type", "application/json")
res, err := c.do(req)
if err != nil {
return nil, fmt.Errorf("failed to make request to the Whereby API: %w", err)
return out, fmt.Errorf("failed to make request to the Whereby API: %w", err)
}

if res.StatusCode < 200 || res.StatusCode > 299 {
return out, handleBadStatus(res)
}

if res.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("unexpected status %d from Whereby", res.StatusCode)
var innerRes meeting
if err := json.NewDecoder(res.Body).Decode(&innerRes); err != nil {
return out, fmt.Errorf("failed to decode payload from Whereby: %w", err)
}

var out meeting
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("failed to decode payload from Whereby: %w", err)
if err := createGetMeetingOutput(&out, innerRes); err != nil {
return out, err
}

return createGetMeetingOutput(out)
return out, nil
}

// createGetMeetingOutput creates the user-friendly output object from the
// internal JSON representation.
func createGetMeetingOutput(in meeting) (*GetMeetingOutput, error) {
var out GetMeetingOutput

out.MeetingID = in.MeetingId
out.URL = in.RoomURL
out.HostURL = in.HostRoomURL
func createGetMeetingOutput(dst *GetMeetingOutput, src meeting) error {
dst.MeetingID = src.MeetingId
dst.URL = src.RoomURL
if hu := src.HostRoomURL; hu != nil {
dst.HostURL = *hu
}

start, err := time.Parse(time.RFC3339, in.StartDate)
if err != nil {
return &out, fmt.Errorf("failed to parse meeting start time %s: %w", in.StartDate, err)
if sd := src.StartDate; sd != nil && *sd != "" {
start, err := time.Parse(time.RFC3339, *sd)
if err != nil {
return err
}
dst.Start = start
}
out.Start = start

end, err := time.Parse(time.RFC3339, in.EndDate)
end, err := time.Parse(time.RFC3339, src.EndDate)
if err != nil {
return &out, fmt.Errorf("failed to parse meeting end time %s: %w", in.EndDate, err)
return err
}
out.End = end
dst.End = end

return &out, nil
return nil
}

// getCreateMeetingInput converts the CreateMeetingInput object into the inner
// representation for JSON marshalling.
func (c *Client) getCreateMeetingInput(in CreateMeetingInput) createMeetingInInt {
var out createMeetingInInt
func (c *Client) getCreateMeetingInput(in CreateMeetingInput) createMeetingPayload {
var out createMeetingPayload
out.IsLocked = in.IsLocked
out.RoomNamePrefix = in.RoomNamePrefix
out.RoomNamePattern = string(in.RoomNamePattern)
Expand All @@ -109,12 +112,14 @@ func (c *Client) getCreateMeetingInput(in CreateMeetingInput) createMeetingInInt

// validateCreateMeetingInput validates the provided CreateMeetingInput.
func validateCreateMeetingInput(input CreateMeetingInput) error {
if input.RoomNamePrefix != "" && !strings.HasPrefix(input.RoomNamePrefix, "/") {
return errors.New(`room name prefix must begin with a slash ("/")`)
if input.RoomNamePrefix != "" {
if strings.ToLower(input.RoomNamePrefix) != input.RoomNamePrefix {
return errors.New("room name should be lowercase")
}
}

if input.Start.IsZero() || input.End.IsZero() {
return errors.New("both start and end times must be specified")
if input.End.IsZero() {
return errors.New("meeting end time must be specified")
}

return nil
Expand Down
3 changes: 2 additions & 1 deletion create_meeting_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package whereby

import (
"bytes"
"context"
"io/ioutil"
"net/http"
"testing"
Expand All @@ -21,7 +22,7 @@ func TestClient_CreateMeeting(t *testing.T) {
c: httpClient,
}

res, err := c.CreateMeeting(CreateMeetingInput{
res, err := c.CreateMeeting(context.Background(), CreateMeetingInput{
IsLocked: true,
Start: mustTimeFunc(t)(time.Parse(time.RFC3339, "2020-05-12T16:42:49Z")),
End: mustTimeFunc(t)(time.Parse(time.RFC3339, "2020-05-12T17:42:49Z")),
Expand Down
12 changes: 3 additions & 9 deletions delete_meeting.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,7 @@ import (
//
// See https://whereby.dev/http-api/#/paths/~1meetings~1{meetingId}/delete for
// more details.
func (c *Client) DeleteMeeting(meetingID string) error {
return c.DeleteMeetingWithContext(context.Background(), meetingID)
}

// DeleteMeetingWithContext is the same as DeleteMeeting with a user-specified
// context.
func (c *Client) DeleteMeetingWithContext(ctx context.Context, meetingID string) error {
func (c *Client) DeleteMeeting(ctx context.Context, meetingID string) error {
endpoint := strings.Replace(deleteMeetingEndpoint, "{meetingId}", meetingID, -1)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
Expand All @@ -31,8 +25,8 @@ func (c *Client) DeleteMeetingWithContext(ctx context.Context, meetingID string)
return fmt.Errorf("failed to make request to the Whereby API: %w", err)
}

if res.StatusCode != http.StatusNoContent {
return fmt.Errorf("unexpected status %d from Whereby", res.StatusCode)
if res.StatusCode < 200 || res.StatusCode > 299 {
return handleBadStatus(res)
}

return nil
Expand Down
3 changes: 2 additions & 1 deletion delete_meeting_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package whereby

import (
"context"
"net/http"
"testing"
)
Expand All @@ -16,7 +17,7 @@ func TestClient_DeleteMeeting(t *testing.T) {
c: httpClient,
}

err := c.DeleteMeeting("1")
err := c.DeleteMeeting(context.Background(), "1")
if err != nil {
t.Errorf("want no err; got %v", err)
}
Expand Down
49 changes: 49 additions & 0 deletions error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package whereby

import (
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
)

var ErrNotFound = errors.New("meeting not found")
var ErrInvalidCredentials = errors.New("invalid credentials")

type RateLimitedError struct {
timeLeft time.Duration
}

func (e RateLimitedError) Error() string {
return "rate limited"
}

func (e RateLimitedError) TimeLeft() time.Duration {
return e.timeLeft
}

func handleBadStatus(r *http.Response) error {
switch r.StatusCode {
case http.StatusNotFound:
return ErrNotFound
case http.StatusUnauthorized:
return ErrInvalidCredentials
case http.StatusTooManyRequests:
var rlr rateLimitResponse
if err := json.NewDecoder(r.Body).Decode(&rlr); err == nil && rlr.Data.MSLeft != nil {
defer r.Body.Close()
return RateLimitedError{timeLeft: time.Duration(*rlr.Data.MSLeft) * time.Millisecond}
}
return RateLimitedError{}
default:
return fmt.Errorf("unexpected status %d from Whereby", r.StatusCode)
}
}

type rateLimitResponse struct {
Description string `json:"error"`
Data struct {
MSLeft *int `json:"ms_left"`
} `json:"data,omitempty"`
}
Loading

0 comments on commit e3ece49

Please sign in to comment.