Skip to content

Commit

Permalink
Merge pull request #1731 from gchq/feature/BAI-1510-add-a-webhook-eve…
Browse files Browse the repository at this point in the history
…nt-for-updating-a-release-and-add-webhook-documentation-to

Feature/bai 1510 add a webhook event for updating a release and add webhook documentation to
  • Loading branch information
PE39806 authored Jan 22, 2025
2 parents 908769d + 4c68808 commit 236dadd
Show file tree
Hide file tree
Showing 6 changed files with 159 additions and 38 deletions.
1 change: 1 addition & 0 deletions backend/src/models/Webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import MongooseDelete from 'mongoose-delete'

export const WebhookEvent = {
CreateRelease: 'createRelease',
UpdateRelease: 'updateRelease',
CreateReviewResponse: 'createReviewResponse',
CreateAccessRequest: 'createAccessRequest',
} as const
Expand Down
7 changes: 7 additions & 0 deletions backend/src/services/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,13 @@ export async function updateRelease(user: UserInterface, modelId: string, semver
throw NotFound(`The requested release was not found.`, { modelId, semver })
}

sendWebhooks(
release.modelId,
WebhookEvent.UpdateRelease,
`Release ${release.semver} has been updated for model ${release.modelId}`,
{ release },
)

return updatedRelease
}

Expand Down
4 changes: 3 additions & 1 deletion backend/src/services/specification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,9 @@ export const webhookInterfaceSchema = z.object({
uri: z.string().openapi({ example: 'http://host:8080/webhook' }),
token: z.string().openapi({ example: 'abcd' }),
insecureSSL: z.boolean().openapi({ example: false }),
events: z.array(z.string()).openapi({ example: ['createRelease', 'createReviewResponse', 'createAccessRequest'] }),
events: z
.array(z.string())
.openapi({ example: ['createRelease', 'updateRelease', 'createReviewResponse', 'createAccessRequest'] }),
active: z.boolean().openapi({ example: true }),

deleted: z.boolean().openapi({ example: false }),
Expand Down
91 changes: 54 additions & 37 deletions backend/test/services/release.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
semverObjectToString,
updateRelease,
} from '../../src/services/release.js'
import { NotFound } from '../../src/utils/error.js'

vi.mock('../../src/connectors/authorisation/index.js')

Expand Down Expand Up @@ -195,6 +196,42 @@ describe('services > release', () => {
expect(releaseModelMocks.save).not.toBeCalled()
})

test('createRelease > release with bailo error', async () => {
fileMocks.getFileById.mockRejectedValueOnce(NotFound('File not found.'))
releaseModelMocks.findOneWithDeleted.mockResolvedValue(null)

expect(() =>
createRelease(
{} as any,
{
semver: 'v1.0.0',
modelCardVersion: 999,
fileIds: ['test'],
} as any,
),
).rejects.toThrowError(/^Unable to create release as the file cannot be found./)

expect(releaseModelMocks.save).not.toBeCalled()
})

test('createRelease > release with generic error', async () => {
fileMocks.getFileById.mockRejectedValueOnce(Error('File not found.'))
releaseModelMocks.findOneWithDeleted.mockResolvedValue(null)

expect(() =>
createRelease(
{} as any,
{
semver: 'v1.0.0',
modelCardVersion: 999,
fileIds: ['test'],
} as any,
),
).rejects.toThrowError(/^File not found./)

expect(releaseModelMocks.save).not.toBeCalled()
})

test('createRelease > release with duplicate file names', async () => {
fileMocks.getFileById.mockResolvedValue({ modelId: 'test_model_id', name: 'test_file.png' })
modelMocks.getModelById.mockResolvedValue({
Expand Down Expand Up @@ -271,12 +308,12 @@ describe('services > release', () => {
})

test('createRelease > should throw Bad Req if the user tries to alter a mirrored model card', async () => {
vi.mocked(authorisation.release).mockResolvedValueOnce({
info: 'Cannot create a release from a mirrored model',
success: false,
id: '',
modelMocks.getModelById.mockResolvedValueOnce({
id: 'test_model_id',
card: { version: 1 },
settings: { mirror: { sourceModelId: '123' } },
})
releaseModelMocks.findOneWithDeleted.mockResolvedValue(null)

expect(() => createRelease({} as any, { semver: 'v1.0.0' } as any)).rejects.toThrowError(
/^Cannot create a release from a mirrored model/,
)
Expand Down Expand Up @@ -309,11 +346,12 @@ describe('services > release', () => {
})

test('updateRelease > should throw Bad Req when attempting to update a release on a mirrored model ', async () => {
vi.mocked(authorisation.release).mockResolvedValue({
info: 'Cannot update a release on a mirrored model.',
success: false,
id: '',
modelMocks.getModelById.mockResolvedValueOnce({
id: 'test_model_id',
card: { version: 1 },
settings: { mirror: { sourceModelId: '123' } },
})

expect(() => updateRelease({} as any, 'model-id', 'v1.0.0', {} as any)).rejects.toThrowError(
/^Cannot update a release on a mirrored model./,
)
Expand All @@ -328,16 +366,12 @@ describe('services > release', () => {
})

test('newReleaseComment > should throw bad request when attempting to create a release comment on a mirrored model', async () => {
vi.mocked(authorisation.release).mockResolvedValue({
info: 'Cannot create a new comment on a mirrored model.',
success: false,
id: '',
})
modelMocks.getModelById.mockResolvedValueOnce({
id: 'test_model_id',
card: { version: 1 },
settings: { mirror: { sourceModelId: '123' } },
})

expect(() => newReleaseComment({} as any, 'model', '1.0.0', 'This is a new comment')).rejects.toThrowError(
/^Cannot create a new comment on a mirrored model./,
)
Expand Down Expand Up @@ -443,21 +477,15 @@ describe('services > release', () => {
expect(releaseModelMocks.save).not.toBeCalled()
})

test('deleteRelease > should throw a bad req when attempting to delete a release from a mirrored model', async () => {
const mockRelease = { _id: 'release' }

releaseModelMocks.findOne.mockResolvedValue(mockRelease)

vi.mocked(authorisation.release).mockImplementation(async (_user, _model, action, _release) => {
if (action === ReleaseAction.View) return { success: true, id: '' }
if (action === ReleaseAction.Delete)
return { success: false, info: 'Cannot delete a file from a mirrored model.', id: '' }

return { success: false, info: 'Unknown action.', id: '' }
test('deleteRelease > should throw a bad req when attempting to delete a release on a mirrored model', async () => {
modelMocks.getModelById.mockResolvedValueOnce({
id: 'test_model_id',
card: { version: 1 },
settings: { mirror: { sourceModelId: '123' } },
})

expect(() => deleteRelease({} as any, 'test', 'test')).rejects.toThrowError(
/^Cannot delete a file from a mirrored model./,
/^Cannot delete a release on a mirrored model./,
)
expect(releaseModelMocks.save).not.toBeCalled()
})
Expand Down Expand Up @@ -500,17 +528,6 @@ describe('services > release', () => {
test('removeFileFromReleases > should throw a bad req when attempting to remove a file from a mirrored model', async () => {
const mockUser: any = { dn: 'test' }
const mockModel: any = { id: 'test', settings: { mirror: { sourceModelId: '123' } } }
const mockRelease = { _id: 'release' }

vi.mocked(authorisation.releases).mockResolvedValue([
{
success: false,
info: 'Cannot remove a file from a mirrored model.',
id: '',
},
])

releaseModelMocks.find.mockResolvedValueOnce([mockRelease, mockRelease])

expect(() => removeFileFromReleases(mockUser, mockModel, '123')).rejects.toThrowError(
/^Cannot remove a file from a mirrored model./,
Expand Down
1 change: 1 addition & 0 deletions frontend/pages/docs/directory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const flatDirectory: Array<DirectoryEntry> = [
{ title: 'Programmatically using Bailo', slug: 'users/programmatically-using-bailo', header: true },
{ title: 'Authentication', slug: 'users/programmatically-using-bailo/authentication' },
{ title: 'Open API', slug: 'users/programmatically-using-bailo/open-api' },
{ title: 'Webhooks', slug: 'users/programmatically-using-bailo/webhooks' },
{ title: 'Python Client', slug: 'users/programmatically-using-bailo/python-client' },
// Administration
{ title: 'Administration', slug: 'administration', header: true },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import DocsWrapper from 'src/docs/DocsWrapper'

# Webhooks

Bailo provides webhooks for programmatic, event-driven interactions with individual models for interfacing with external
applications.

## Events

A Bailo model's webhook events are:

- `createRelease`
- `updateRelease`
- `createReviewResponse`
- `createAccessRequest`

To view and configure webhooks for a given model, refer to the Webhook section of our [api docs](./open-api).

## Request Format

When a webhook is triggered, it will send a `POST` request to the webhook's URI.

The webhook will include `"Authorization": "Bearer <token>"` in the request `headers` if the webhook has a token.

The body of the webhook's request will vary depending on the type of hook.

### createRelease & updateRelease

```json
{
"event": "{createRelease|updateRelease}",
"description": "A release event happened",
"modelId": "abc123",
"modelCardVersion": 1,
"semver": "0.0.1",
"notes": "Initial release",
"minor": false,
"draft": false,
"fileIds": ["0123456789abcdef01234567"],
"images": [
{
"repository": "abc123",
"name": "some-docker-image",
"tag": "1.0.0"
}
],
"deleted": false,
"createdBy": "user",
"createdAt": "2025-01-21T12:00:00.000Z",
"updatedAt": "2025-01-21T12:00:00.000Z"
}
```

### createReviewResponse

```json
{
"event": "createReviewResponse",
"description": "A review response was created",
"semver": "0.0.1",
"accessRequestId": "123456789abcdef012345678",
"modelId": "abc123",
"kind": "{release|access}"
"role": "mtr",
"createdAt": "2025-01-21T12:00:00.000Z",
"updatedAt": "2025-01-21T12:00:00.000Z"
}
```

### createAccessRequest

```json
{
"event": "createAccessRequest",
"description": "An access request was created",
"id": "some-access-request-mno456",
"modelId": "abc123",
"schemaId": "minimal-access-request-general-v10",
"metadata": {
"overview": {
"name": "some access request",
"entities": ["user:user"],
"endDate": "2025-01-21"
}
},
"deleted": false,
"createdBy": "user",
"createdAt": "2025-01-21T12:00:00.000Z",
"updatedAt": "2025-01-21T12:00:00.000Z"
}
```

export default ({ children }) => <DocsWrapper>{children}</DocsWrapper>

0 comments on commit 236dadd

Please sign in to comment.