Skip to content
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

Add create observation endpoint #42

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,118 @@ To destroy the app (delete all data and project invites), run:
```sh
flyctl destroy --app <your-app-name>
```

## Usage

### API Examples

All API requests require a Bearer token that matches the `SERVER_BEARER_TOKEN` environment variable.

In the examples below, replace `<SERVER_BEARER_TOKEN>` with your actual token, `<yourserver.com>` with your server's address, and provide the necessary data for each request.

#### Add a Project

To add a new project to the server, send a POST request to `/projects` with the project details.

```bash
curl -X PUT https://yourserver.com/projects \
-H "Authorization: Bearer <SERVER_BEARER_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"projectName": "Your Project Name",
"projectKey": "<hex-encoded project key>",
"encryptionKeys": {
"auth": "<hex-encoded auth key>",
"config": "<hex-encoded config key>",
"data": "<hex-encoded data key>",
"blobIndex": "<hex-encoded blobIndex key>",
"blob": "<hex-encoded blob key>"
}
}'
```

#### Get Projects

```bash
curl \
-H 'Authorization: Bearer <SERVER_BEARER_TOKEN>' \
'https://yourserver.com/projects'
```

#### Create an Observation

Add a new observation to a project by sending a POST request to `/projects/:projectPublicId/observations` with the observation data.

```bash
curl -X PUT https://yourserver.com/projects/<projectPublicId>/observations \
-H "Authorization: Bearer <SERVER_BEARER_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"lat": <latitude>,
"lon": <longitude>,
"attachments": [
{
"driveDiscoveryId": "<driveDiscoveryId>",
"type": "photo",
"name": "<filename>"
}
],
"tags": ["tag1", "tag2"]
}'
```

#### Get Observations

Retrieve observations for a project by sending a GET request to `/projects/:projectPublicId/observations`.

```bash
curl -X GET https://yourserver.com/projects/<projectPublicId>/observations \
-H "Authorization: Bearer <SERVER_BEARER_TOKEN>"
```

Replace `<projectPublicId>` with the public ID of your project.

#### Get an Attachment

Fetch an attachment associated with an observation.

```bash
curl -X GET "https://yourserver.com/projects/<projectPublicId>/attachments/<driveDiscoveryId>/<type>/<name>?variant=<variant>" \
-H "Authorization: Bearer <SERVER_BEARER_TOKEN>"
```

- Replace `<projectPublicId>` with your project's public ID.
- Replace `<driveDiscoveryId>` with the drive discovery ID of the attachment.
- Replace `<type>` with the attachment type (`photo` or `audio`).
- Replace `<name>` with the attachment file name.
- `<variant>` is optional and can be `original`, `preview`, or `thumbnail` for photos. For audio, only `original` is valid.

#### Create a Remote Alert

Send a POST request to `/projects/:projectPublicId/remoteDetectionAlerts` with the alert data.

```bash
curl -X POST https://yourserver.com/projects/<projectPublicId>/remoteDetectionAlerts \
-H "Authorization: Bearer <SERVER_BEARER_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"detectionDateStart": "<ISO timestamp>",
"detectionDateEnd": "<ISO timestamp>",
"sourceId": "<source id>",
"metadata": {
"alert_type": "<alert type>"
},
"geometry": {
"type": "Point",
"coordinates": [<longitude>, <latitude>]
}
}'
```

#### Healthcheck

Check the health of the server by making a GET request to `/healthcheck`. This endpoint does not require authentication.

```bash
curl -X GET https://yourserver.com/healthcheck
```
49 changes: 49 additions & 0 deletions src/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,55 @@ export default async function routes(
},
)

fastify.put(
'/projects/:projectPublicId/observation',
{
schema: {
params: Type.Object({
projectPublicId: BASE32_STRING_32_BYTES,
}),
body: schemas.observationToAdd,
response: {
201: Type.Literal(''),
'4xx': schemas.errorResponse,
},
},
async preHandler(req) {
verifyBearerAuth(req)
await ensureProjectExists(this, req)
},
},
/**
* @this {FastifyInstance}
*/
async function (req, reply) {
const { projectPublicId } = req.params
const project = await this.comapeo.getProject(projectPublicId)
const observationData = {
schemaName: /** @type {const} */ ('observation'),
...req.body,
attachments: (req.body.attachments || []).map((attachment) => ({
...attachment,
hash: '', // Required by schema but not used
})),
tags: req.body.tags || {},
metadata: req.body.metadata || {
manualLocation: false,
position: {
mocked: false,
timestamp: new Date().toISOString(),
coords: {
latitude: req.body.lat,
longitude: req.body.lon,
},
},
},
}
await project.observation.create(observationData)
reply.status(201).send()
},
)

fastify.post(
'/projects/:projectPublicId/remoteDetectionAlerts',
{
Expand Down
46 changes: 46 additions & 0 deletions src/schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,52 @@ export const projectToAdd = Type.Object({
}),
})

export const observationToAdd = Type.Object({
lat: latitude,
lon: longitude,
attachments: Type.Optional(
Type.Array(
Type.Object({
driveDiscoveryId: Type.String(),
type: Type.Union([Type.Literal('photo'), Type.Literal('audio')]),
name: Type.String(),
}),
),
),
tags: Type.Optional(
Type.Record(
Type.String(),
Type.Union([
Type.Boolean(),
Type.Number(),
Type.String(),
Type.Null(),
Type.Array(
Type.Union([
Type.Boolean(),
Type.Number(),
Type.String(),
Type.Null(),
]),
),
]),
),
),
metadata: Type.Optional(
Type.Object({
manualLocation: Type.Boolean(),
position: Type.Object({
mocked: Type.Boolean(),
timestamp: Type.String(),
coords: Type.Object({
latitude: Type.Number(),
longitude: Type.Number(),
}),
}),
}),
),
})

export const observationResult = Type.Object({
docId: Type.String(),
createdAt: dateTimeString,
Expand Down
86 changes: 86 additions & 0 deletions test/add-observation-endpoint.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto'

import assert from 'node:assert/strict'
import test from 'node:test'

import {
BEARER_TOKEN,
createTestServer,
randomAddProjectBody,
randomProjectPublicId,
} from './test-helpers.js'

test('adding an observation', async (t) => {
const server = createTestServer(t)
const projectBody = randomAddProjectBody()
const projectPublicId = projectKeyToPublicId(
Buffer.from(projectBody.projectKey, 'hex'),
)

// First create a project
const addProjectResponse = await server.inject({
method: 'PUT',
url: '/projects',
body: projectBody,
})
assert.equal(addProjectResponse.statusCode, 200)

// Generate mock observation data
const observationData = {
lat: 51.5074,
lon: -0.1278,
tags: {
notes: 'Test observation',
},
attachments: [],
}

// Add observation to project
const response = await server.inject({
method: 'PUT',
url: `/projects/${projectPublicId}/observation`,
headers: {
Authorization: `Bearer ${BEARER_TOKEN}`,
},
body: observationData,
})

assert.equal(response.statusCode, 201)
})

test('returns 401 if no auth provided', async (t) => {
const server = createTestServer(t)
const projectId = randomProjectPublicId()

const response = await server.inject({
method: 'PUT',
url: `/projects/${projectId}/observation`,
body: {
lat: 51.5074,
lon: -0.1278,
},
})

assert.equal(response.statusCode, 401)
assert.equal(response.json().error.code, 'UNAUTHORIZED')
})

test('returns 404 if project does not exist', async (t) => {
const server = createTestServer(t)
const projectId = randomProjectPublicId()

const response = await server.inject({
method: 'PUT',
url: `/projects/${projectId}/observation`,
headers: {
Authorization: `Bearer ${BEARER_TOKEN}`,
},
body: {
lat: 51.5074,
lon: -0.1278,
},
})

assert.equal(response.statusCode, 404)
assert.equal(response.json().error.code, 'PROJECT_NOT_FOUND')
})
Loading