-
Notifications
You must be signed in to change notification settings - Fork 26
[ENG-3815] feat(microsoft): Impl Create Subscription #2878
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: cobalt0s/microsoft-webhook-verifier
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package components | ||
|
|
||
| import "time" | ||
|
|
||
| // Clock provides the current time, enabling deterministic testing of time-dependent logic. | ||
| // Implementations include production (real time) and test (fixed time) variants. | ||
| type Clock interface { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am strongly against adding new components that are just wrappers of native packages. This only introduces onboarding complexity and load. Please remove this type and just the native package. |
||
| Now() time.Time | ||
| } | ||
|
|
||
| // RealClock returns the current wall-clock time using time.Now(). | ||
| type RealClock struct{} | ||
|
|
||
| func NewRealClock() *RealClock { | ||
| return new(RealClock) | ||
| } | ||
|
|
||
| func (RealClock) Now() time.Time { | ||
| return time.Now() | ||
| } | ||
|
|
||
| // FixedClock returns a fixed timestamp for reproducible tests. | ||
| // Use time.Date(...) or time.Now().Add(...) to set specific values. | ||
| type FixedClock struct { | ||
| t time.Time | ||
| } | ||
|
|
||
| // NewFixedClock creates a FixedClock at the given time. | ||
| func NewFixedClock(t time.Time) *FixedClock { | ||
| return &FixedClock{t: t} | ||
| } | ||
|
|
||
| func (c *FixedClock) Now() time.Time { | ||
| return c.t | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,3 +4,10 @@ type Pair[L, R any] struct { | |
| Left L | ||
| Right R | ||
| } | ||
|
|
||
| func NewPair[L, R any](left L, right R) *Pair[L, R] { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. unnecessary util func. Please remove. |
||
| return &Pair[L, R]{ | ||
| Left: left, | ||
| Right: right, | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,224 @@ | ||
| package subscriber | ||
|
|
||
| import ( | ||
| "context" | ||
| "net/http" | ||
| "time" | ||
|
|
||
| "github.com/amp-labs/connectors/common" | ||
| "github.com/amp-labs/connectors/internal/components" | ||
| "github.com/amp-labs/connectors/internal/datautils" | ||
| "github.com/amp-labs/connectors/providers/microsoft/internal/batch" | ||
| "github.com/amp-labs/connectors/providers/microsoft/internal/webhook" | ||
| ) | ||
|
|
||
| const subscriptionExpirationWindow = 5 * time.Hour | ||
|
|
||
| // Subscribe creates a Microsoft Graph subscription for the specified objects and events. | ||
| // | ||
| // nolint:lll | ||
| // See the [request body]( https://learn.microsoft.com/en-us/graph/api/subscription-post-subscriptions?view=graph-rest-1.0&tabs=http#request-body) for details. | ||
| // Supported resources are listed [here](https://learn.microsoft.com/en-us/graph/api/resources/change-notifications-api-overview?view=graph-rest-1.0). | ||
| func (s Strategy) Subscribe( | ||
| ctx context.Context, | ||
| params common.SubscribeParams, | ||
| ) (*common.SubscriptionResult, error) { | ||
| if err := params.ValidateParams(); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return s.createSubscription(ctx, params) | ||
| } | ||
|
|
||
| // createSubscription creates subscriptions using batch requests for efficiency. | ||
| // Handles rollback on partial failures to maintain consistency. | ||
| // Pre-existing subscriptions for the same resource may result in duplicates (handled only by UpdateSubscription). | ||
| func (s Strategy) createSubscription( | ||
| ctx context.Context, | ||
| params common.SubscribeParams, | ||
| ) (*common.SubscriptionResult, error) { | ||
| batchParams, err := s.paramsForBatchCreateSubscriptions(params) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| bundledResponse := batch.Execute[SubscriptionResource](ctx, s.batchStrategy, batchParams) | ||
| state := getStateFromCreateResponse(bundledResponse) | ||
|
|
||
| if len(bundledResponse.Errors) != 0 { | ||
| // Some requests failed; initiate rollback. | ||
| return s.rollbackSubscriptionCreation(ctx, params, state, bundledResponse) | ||
| } | ||
|
|
||
| return &common.SubscriptionResult{ | ||
| Result: Output{}, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is no need to have an empty struct type as a placeholder. |
||
| ObjectEvents: state, | ||
| Status: common.SubscriptionStatusSuccess, | ||
| }, nil | ||
| } | ||
|
|
||
| // paramsForBatchCreateSubscriptions prepares batch parameters for creating multiple subscriptions. | ||
| // Constructs payloads for each object-event combination using the webhook URL. | ||
| func (s Strategy) paramsForBatchCreateSubscriptions(params common.SubscribeParams) (*batch.Params, error) { | ||
| input, err := s.TypedSubscriptionRequest(params) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| webhookURL := input.WebhookURL | ||
|
|
||
| url, err := s.getSubscriptionURL() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| batchParams := &batch.Params{} | ||
|
|
||
| for objectName, events := range params.SubscriptionEvents { | ||
| requestID := batch.RequestID(objectName) | ||
| body := newPayloadCreateSubscription(objectName, events, webhookURL, s.clock) | ||
| batchParams.WithRequest(requestID, http.MethodPost, url, body, map[string]any{ | ||
| "Content-Type": "application/json", | ||
| }) | ||
| } | ||
|
|
||
| return batchParams, nil | ||
| } | ||
|
|
||
| // getStateFromCreateResponse extracts the subscription state from batch responses. | ||
| // Successful responses set the events state; errors result in empty ObjectEvents state. | ||
| func getStateFromCreateResponse(response *batch.Result[SubscriptionResource]) State { | ||
| result := make(State) | ||
|
|
||
| for objectName, envelope := range response.Responses { | ||
| // Map successful subscription to its events. | ||
| subscription := envelope.Data | ||
| result[ObjectName(objectName)] = common.ObjectEvents{ | ||
| Events: subscription.ChangeType.EventTypes(), | ||
| WatchFields: nil, | ||
| WatchFieldsAll: false, | ||
| PassThroughEvents: nil, | ||
| } | ||
| } | ||
|
|
||
| for objectName := range response.Errors { | ||
| // Failed requests yield no subscription. | ||
| result[ObjectName(objectName)] = common.ObjectEvents{} | ||
| } | ||
|
|
||
| return result | ||
| } | ||
|
|
||
| // rollbackSubscriptionCreation deletes successfully created subscriptions on partial failure. | ||
| // It updates state to reflect remaining subscriptions after attempted rollback. | ||
| // Returns appropriate status based on rollback success. | ||
| func (s Strategy) rollbackSubscriptionCreation( | ||
| ctx context.Context, | ||
| params common.SubscribeParams, | ||
| state State, | ||
| partialCreation *batch.Result[SubscriptionResource], | ||
| ) (*common.SubscriptionResult, error) { | ||
| requestsRegistry := make(datautils.Map[SubscriptionID, ObjectName]) | ||
|
|
||
| for _, envelope := range partialCreation.Responses { | ||
| subscription := envelope.Data | ||
| requestsRegistry[subscription.ID] = ObjectName(subscription.Resource) | ||
| } | ||
|
|
||
| bundledResponse, err := s.removeSubscriptionsByIDs(ctx, requestsRegistry.Keys()) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| if len(bundledResponse.Errors) == 0 { | ||
| // Full rollback succeeded. | ||
| objectNames := datautils.FromMap(params.SubscriptionEvents).Keys() | ||
|
|
||
| return &common.SubscriptionResult{ | ||
| Result: Output{}, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nil is sufficient. or just leave it empty. |
||
| ObjectEvents: newState(objectNames), | ||
| Status: common.SubscriptionStatusFailed, | ||
| }, nil | ||
| } | ||
|
|
||
| // Partial rollback; track remaining subscriptions. | ||
| existingObjects := datautils.NewSet[ObjectName]() | ||
|
|
||
| for requestID := range bundledResponse.Errors { | ||
| // Convert request ID back to object. | ||
| id := SubscriptionID(requestID) | ||
| objectName := requestsRegistry[id] | ||
| existingObjects.AddOne(objectName) | ||
| } | ||
|
|
||
| allObjects := datautils.FromMap(params.SubscriptionEvents).KeySet() | ||
| removedObjects := allObjects.Subtract(existingObjects) | ||
|
|
||
| // Clear state for successfully removed objects. | ||
| for _, objectName := range removedObjects { | ||
| state[objectName] = common.ObjectEvents{} | ||
| } | ||
|
|
||
| return &common.SubscriptionResult{ | ||
| Result: Output{}, | ||
| ObjectEvents: state, | ||
| Status: common.SubscriptionStatusFailedToRollback, | ||
| }, nil | ||
| } | ||
|
|
||
| // SubscriptionResource represents a Microsoft Graph subscription. | ||
| // See [properties](https://learn.microsoft.com/en-us/graph/api/resources/subscription?view=graph-rest-1.0#properties). | ||
| // | ||
| // Custom usage: clientState field is repurposed to store ObjectName for identification. | ||
| // Ignored fields: | ||
| // | ||
| // encryptionCertificateId, encryptionCertificate, lifecycleNotificationUrl, | ||
| // notificationQueryOptions, notificationUrlAppId. | ||
| type SubscriptionResource struct { | ||
| // ID is the unique subscription identifier returned by POST/GET/PATCH requests. | ||
| ID SubscriptionID `json:"id,omitempty"` | ||
| // ChangeType specifies the event types (created, updated, deleted) to subscribe to. | ||
| ChangeType webhook.ChangeType `json:"changeType,omitempty"` | ||
| // ObjectName uses the clientState field to store the connector's object name for identification. | ||
| ObjectName ObjectName `json:"clientState,omitempty"` | ||
| // WebhookURL is the notification URL where Microsoft Graph sends change notifications. | ||
| WebhookURL string `json:"notificationUrl,omitempty"` | ||
| // Resource identifies the Microsoft Graph resource being monitored (e.g., "me/messages"). | ||
| Resource string `json:"resource,omitempty"` | ||
| // ExpirationDateTime is the UTC datetime when the subscription expires and auto-deletes. | ||
| // Must respect per-resource maximum lifetimes (ranges from 5 hours to 30 days). | ||
| // https://learn.microsoft.com/en-us/graph/api/resources/subscription?view=graph-rest-1.0#subscription-lifetime | ||
| ExpirationDateTime time.Time `json:"expirationDateTime"` | ||
| // IncludeResourceData is set to false. This is to avoid encryption requirements. | ||
| // Resource data is fetched separately via ReadByIds, therefore it is not needed. | ||
| IncludeResourceData bool `json:"includeResourceData,omitempty"` | ||
| } | ||
|
|
||
| type SubscriptionID string | ||
|
|
||
| // newPayloadCreateSubscription constructs a subscription payload for creation. | ||
| // Uses clientState to store objectName for identification. | ||
| // Expiration is set to 5 hours to safely fit common maximums (e.g., presence: 1h excluded; others 3-30 days). | ||
| // | ||
| // nolint:lll | ||
| // See [lifetime limits](https://learn.microsoft.com/en-us/graph/api/resources/subscription?view=graph-rest-1.0#subscription-lifetime) | ||
| func newPayloadCreateSubscription( | ||
| objectName ObjectName, | ||
| events common.ObjectEvents, | ||
| webhookURL string, | ||
| clock components.Clock, | ||
| ) SubscriptionResource { | ||
| resource := objectName.String() | ||
|
|
||
| fiveHoursFromNow := clock.Now().Add(subscriptionExpirationWindow) | ||
| body := SubscriptionResource{ | ||
| ChangeType: webhook.NewChangeType(events.Events), | ||
| ObjectName: objectName, | ||
| WebhookURL: webhookURL, | ||
| Resource: resource, | ||
| ExpirationDateTime: fiveHoursFromNow, | ||
| IncludeResourceData: false, | ||
| } | ||
|
|
||
| return body | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not sure what is the reason for ignoring these and where exactly this data is stored. The directory names are good and shouldn't be excluded.