Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(app): add strict schema validation endpoint
Browse files Browse the repository at this point in the history
benforshey committed May 24, 2024

Verified

This commit was signed with the committer’s verified signature.
jcubic Jakub T. Jankiewicz
1 parent 84f21fd commit aca2b83
Showing 11 changed files with 220 additions and 7 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -5,8 +5,9 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [1.10.0] - 2024-05-24

### Added

- `/api/models/[modelName]/validate` endpoint for strict validation of documents against their model's schema
- changelog
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "meditor",
"version": "1.9.12",
"version": "1.10.0",
"description": "mEditor, the model editor",
"directories": {
"example": "examples",
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"severity": "normal",
"title": "Forward stream data temporarily unavailable from AWS cloud",
"start": "2023-12-26 12:00:00-05:00",
"expiration": "2024-01-01 12:00:00-05:00",
"body": "<p>As of 9:00 AM Tuesday, December 26, 2023, granule ingest into AWS cloud was paused due to an ongoing operations issue. Users who use search interfaces, including Giovanni, will have issues discovering cloud-enabled data that have been archived in the past couple of days. This is not impacting users who directly access and download data from on-premises archive hosts at the GES DISC. We plan to resolve this issue and resume granule ingest by Sunday, December 31, 2023, though this is subject to change.</p>\n"
}
Original file line number Diff line number Diff line change
@@ -45,6 +45,7 @@
},
"title": "TRMM GrADS DODS Server",
"body": "<p>The TRMM GDS services are temporarily suspended until the respective GrADS-DODS server is updated, and we can ensure it is properly maintained. The TRMM GDS has been exposing incomplete data series, as well as data we can no longer provide support for. The tentative timeline to resume this service is February, 2017.</p><p>Meanwhile, Users are encouraged to explore the OPeNDAP hyrax server:</p><p>http://disc2.gesdisc.eosdis.nasa.gov/opendap/TRMM_RT/contents.html </p><div></div>\n",
"severity": "normal",
"expiration": "2016-11-01T04:00:00Z",
"start": "2016-10-14T04:00:00Z",
"tags": [],
46 changes: 44 additions & 2 deletions packages/app/documents/__tests__/document.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { Db } from 'mongodb'
import { wait } from '../../utils/time'
import * as emailNotifications from '../../email-notifications/service'
import getDb from '../../lib/mongodb'
import SpatialSearchIssue from '../../models/__tests__/__fixtures__/alerts/spatial_search_issue.json'
@@ -13,6 +12,7 @@ import collectionMetadataModel from '../../models/__tests__/__fixtures__/models/
import faqsModel from '../../models/__tests__/__fixtures__/models/faqs.json'
import modelsModel from '../../models/__tests__/__fixtures__/models/models.json'
import * as publicationQueue from '../../publication-queue/service'
import { wait } from '../../utils/time'
import editPublishCmrWorkflow from '../../workflows/__tests__/__fixtures__/edit-publish-cmr.json'
import editPublishWorkflow from '../../workflows/__tests__/__fixtures__/edit-publish.json'
import editReviewPublishWorkflow from '../../workflows/__tests__/__fixtures__/edit-review-publish.json'
@@ -30,16 +30,18 @@ import {
getDocumentPublications,
getDocumentsForModel,
isPublishableWithWorkflowSupport,
strictValidateDocument,
} from '../service'
import alertFromGql from './__fixtures__/alertFromGql.json'
import alertOnlyDocument from './__fixtures__/alertOnlyDocument.json'
import alertsAfterCreateDocumentModification from './__fixtures__/alerts-after-createDocument-modifies-state.json'
import alertsAfterV1Modification from './__fixtures__/alerts-after-v1-putDocument-modifies-state.json'
import alertsBeforeModification from './__fixtures__/alerts-before-modified-state.json'
import alertWithHistory from './__fixtures__/alertWithHistory.json'
import alertWithPublication from './__fixtures__/alertWithPublication.json'
import duplicateEdgesWorkflow from './__fixtures__/duplicate-edges-workflow.json'
import workflowEdges from './__fixtures__/workflowEdges.json'
import workflowWithTwoInitialNodes from './__fixtures__/workflow-with-two-initial-nodes.json'
import workflowEdges from './__fixtures__/workflowEdges.json'

describe('Documents', () => {
let db: Db
@@ -1208,4 +1210,44 @@ describe('Documents', () => {
expect(clone.title).toEqual('Eggs')
})
})

describe('validateDocument', () => {
it(`returns no error and the original document when validation passes`, async () => {
const [error, document] = await strictValidateDocument(
alertOnlyDocument,
'Alerts'
)

expect(error).toBeNull()
expect(document).toEqual(alertOnlyDocument)
})

it(`returns errors and no document when validation fails`, async () => {
const docClone = JSON.parse(JSON.stringify(alertOnlyDocument)) //* structuredClone requires NodeJS > 17
docClone.severity = 'pretty serious'

const [error, document] = await strictValidateDocument(docClone, 'Alerts')

expect(error).toMatchInlineSnapshot(
`[Error: Document "Forward stream data temporarily unavailable from AWS cloud" does not validate against the schema for model "Alerts": [{"property":"instance.severity","name":"enum","argument":["normal","emergency"],"message":"is not one of enum values","stack":"instance.severity is not one of enum values"}]]`
)
expect(document).toBeNull()
})

it(`does not allow additional properties`, async () => {
const docWithMetadata = await db
.collection('Alerts')
.findOne({ title: 'TRMM GrADS DODS Server' })

const [error, document] = await strictValidateDocument(
docWithMetadata,
'Alerts'
)

expect(error).toMatchInlineSnapshot(
`[Error: Document "TRMM GrADS DODS Server" does not validate against the schema for model "Alerts": [{"property":"instance","name":"additionalProperties","argument":"_id","message":"is not allowed to have the additional property \\"_id\\"","stack":"instance is not allowed to have the additional property \\"_id\\""},{"property":"instance","name":"additionalProperties","argument":"x-meditor","message":"is not allowed to have the additional property \\"x-meditor\\"","stack":"instance is not allowed to have the additional property \\"x-meditor\\""}]]`
)
expect(document).toBeNull()
})
})
})
47 changes: 47 additions & 0 deletions packages/app/documents/service.ts
Original file line number Diff line number Diff line change
@@ -725,3 +725,50 @@ function getWorkflowEdgesMatchingSourceAndTarget(
edge => edge.source === source && edge.target === target
)
}

/**
* Validates a document against its schema strictly, allowing no additiona properties.
*/
export async function strictValidateDocument(
documentToValidate: any,
modelName: string
): Promise<ErrorData<Document>> {
try {
//* Get the model to validate its schema and the workflow so that we can find information about the draft node, which is the only node that applies to creating a document.
const [modelWithWorkflowError, modelWithWorkflow] =
await getModelWithWorkflow(modelName, undefined, {
populateMacroTemplates: true,
})

if (modelWithWorkflowError) {
throw modelWithWorkflowError
}

const { schema, titleProperty } = modelWithWorkflow

const { errors } = validate(documentToValidate, {
...JSON.parse(schema),
additionalProperties: false,
})

//* Unlike most use-cases, we don't want to throw for a validation error; we just return it.
if (errors.length) {
const validationError = new HttpException(
ErrorCode.ValidationError,
`Document "${
documentToValidate[titleProperty]
}" does not validate against the schema for model "${modelName}": ${JSON.stringify(
errors.map(formatValidationErrorMessage)
)}`
)

return [validationError, null]
}

return [null, documentToValidate]
} catch (error) {
log.error(error)

return [error, null]
}
}
2 changes: 1 addition & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "meditor",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
63 changes: 63 additions & 0 deletions packages/app/pages/api/models/[modelName]/validate/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { getLoggedInUser } from 'auth/user'
import { strictValidateDocument } from 'documents/service'
import { getModelWithWorkflow, userCanAccessModel } from 'models/service'
import type { NextApiRequest, NextApiResponse } from 'next'
import { respondAsJson } from 'utils/api'
import { apiError, ErrorCode, HttpException } from 'utils/errors'
import { safeParseJSON } from 'utils/json'

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const modelName = decodeURIComponent(req.query.modelName.toString())
const user = await getLoggedInUser(req, res)

if (!userCanAccessModel(modelName, user)) {
return apiError(
new HttpException(
ErrorCode.ForbiddenError,
'User does not have access to the requested model'
),
res
)
}

switch (req.method) {
//* GET returns the model's schema that will be used to valide the document.
case 'GET': {
const [modelWithWorkflowError, modelWithWorkflow] =
await getModelWithWorkflow(modelName, undefined, {
populateMacroTemplates: true,
})

if (modelWithWorkflowError) {
return apiError(modelWithWorkflowError, res)
}

return respondAsJson(JSON.parse(modelWithWorkflow.schema), req, res)
}

//* Unlike most POST endpoints, this allows unauthenticated access.
case 'POST': {
const [parsingError, parsedDocument] = safeParseJSON(req.body)

if (parsingError) {
return apiError(parsingError, res)
}

const [validationError, validDocument] = await strictValidateDocument(
parsedDocument,
modelName
)

if (validationError) {
return apiError(validationError, res)
}

return respondAsJson(validDocument, req, res, {
httpStatusCode: 200,
})
}

default:
return res.status(405).end()
}
}
2 changes: 0 additions & 2 deletions packages/app/search/__tests__/search.test.ts
Original file line number Diff line number Diff line change
@@ -322,8 +322,6 @@ describe('search', () => {
pageNumber
)

console.log(searchError)

expect(searchError).toBe(null)
expect(searchResults.results.length).toBe(0)
expect(searchResults.results).toStrictEqual([])
8 changes: 8 additions & 0 deletions packages/docs/README.md
Original file line number Diff line number Diff line change
@@ -38,3 +38,11 @@ npm run build
```

This command generates static content into the `build` directory and can be served using any static contents hosting service.

### Generate API Docs

```bash
npm run gen-api-docs meditor
```

This command generates static content into the `build` directory for the API documentation and can be served using any static contents hosting service.
46 changes: 46 additions & 0 deletions packages/docs/api-spec/v1.yaml
Original file line number Diff line number Diff line change
@@ -25,6 +25,8 @@ tags:
x-displayName: Publications
- name: search
x-displayName: Search
- name: validate
x-displayName: Validate
paths:
/models:
get:
@@ -580,3 +582,47 @@ paths:
description: Bad Request
'500':
description: Internal Server Error
/models/{modelName}/validate:
get:
tags:
- validate
summary: get model's schema
description: Gets the schema for the model to which the document belongs.
operationId: getDocumentSchema
parameters:
- name: modelName
in: path
description: name of model
required: true
schema:
type: string
responses:
'200':
description: OK
'404':
description: Not Found
'500':
description: Internal Server Error
post:
tags:
- validate
summary: validate a document against its model's schema
description: Strictly validate a document againt the schema stored in mEditor for the document's model.
operationId: vaidateDocument
parameters:
- name: modelName
in: path
description: name of model
required: true
schema:
type: string
requestBody:
content:
application/json: {}
description: document to validate
required: true
responses:
'200':
description: Success (Validation Passes)
'400':
description: Bad Request (Validation Fails)

0 comments on commit aca2b83

Please sign in to comment.