Skip to content

Commit

Permalink
feat: add jsonschema validation for backend (#15)
Browse files Browse the repository at this point in the history
* feat: validation schema for sign up endpoint

* style: 🎨 fix code style issues with Black

* feat: validate email with email_validator

* feat: schema validation for login

* feat: schema validation for user details PUT

* feat: POST, PUT schemas added for /study endpoint

* feat: schema validation added for arm metadata

* style: 🎨 fix code style issues with Black

* feat: schema validation for available ipd

* style: 🎨 fix code style issues with Black

* feat: study contact schema validation added

* feat: schema validation for collaborators

* style: 🎨 fix code style issues with Black

* feat: conditions schema validation added"

* style: 🎨 fix code style issues with Black

* feat: send better error message for invalid email

* feat: schema validation for study description

* feat: schema validation for study design

* feat: schema validation for study intervention

* schema validation for ipd sharing

* feat: schema validation for study link

* fix: fixed import typo

* feat: schema validation for other metadata

* feat: schema validation for study location

* feat: schema validation for oversight endpoint

* feat: schema validation for study reference

* feat: schema validation for sponsors collaborators

* feat: schema validate password meets all criterias on signup

* wip: additional requirements/checks for schema validations

* style: 🎨 fix code style issues with Black

* feat: study design carries additional validations

* feat: study contact carries additional validations

* feat: study eligibility carries additional validations

* feat: study intervention carries additional validations

* feat: overal official has enums and other validations

* feat: enums and other validations for location endpoint

* feat: enums and additional validations for ipd sharing

* feat: additional validations for study_status

* feat: additional validations for study reference

* feat: additional validations for available ipds

* feat: enums added and additional validations for sponsor collaborators

* additional enums and validations for study links

* refactor: removed marshal_with for validation errors

* feat: additional validations added for study contact

* feat: enums added for enrollment_type in design endpoint

* refactor: other metadata validation formatted

* feat: minimum length added for validating sponsor collaborators

* fix: remove double imports

* refactor: isort imports

* style: 🎨 fix code style issues with Black

* feat: intervention schema validation added

* feat: conditional requirements added to status metadata

* feat: conditional validations added for sponsor collaborators

* refactor: endpoint comment added

* feat: additional validations for study identification

* style: 🎨 fix code style issues with Black

* feat: conditional validations and enums added for final endpoints

* style: 🎨 fix code style issues with Black

* fix: re-add study type for conditions endpoint

* fix: remove last marshal_with on put, fix type def for data (overall-official)

* fix: update pytest fixtures for validation schemas

* style: 🎨 fix code style issues with Black

* fix: updated pytests for schema validations

* style: 🎨 fix code style issues with Black

* fix: fix: invalid characters for regex

* fix: update flake8 errors

* style: 🎨 fix code style issues with Black

* fix: update for flake8 errors

* style: 🎨 fix code style issues with Black

* fix: updating for flake8 issues

* style: 🎨 fix code style issues with Black

* fix: correct issues for pyflake8

* style: 🎨 fix code style issues with Black

* fix: type def for request.json

* fix: update for pylint issue

* 🐛 fix: update schemas

---------

Co-authored-by: Lint Action <[email protected]>
Co-authored-by: Sanjay Soundarajan <[email protected]>
  • Loading branch information
3 people authored Oct 19, 2023
1 parent 1b4e46f commit 5386ce2
Show file tree
Hide file tree
Showing 32 changed files with 1,444 additions and 167 deletions.
105 changes: 100 additions & 5 deletions apis/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
from typing import Any, Union

import jwt
from email_validator import EmailNotValidError, validate_email
from flask import g, make_response, request
from flask_restx import Namespace, Resource, fields
from jsonschema import FormatChecker, ValidationError, validate

import model

Expand Down Expand Up @@ -46,10 +48,71 @@ class SignUpUser(Resource):
def post(self):
"""signs up the new users and saves data in DB"""
data: Union[Any, dict] = request.json
# TODO data[email doesnt exist then raise error; json validation library
pattern = r"^[\w\.-]+@[\w\.-]+\.\w+$"
if not data["email_address"] or not re.match(pattern, data["email_address"]):
return "Email address is invalid", 422

def validate_is_valid_email(instance):
# Turn on check_deliverability
# for first-time validations like on account creation pages (but not
# login pages).
email_address = instance
try:
validate_email(email_address, check_deliverability=False)
return True
except EmailNotValidError as e:
raise ValidationError("Invalid email address format") from e

def validate_password(instance):
password = instance
# Check if password is at least 8 characters long
if len(password) < 8:
raise ValidationError("Password must be at least 8 characters long")

# Check if password contains at least one lowercase letter
if not re.search(r"[a-z]", password):
raise ValidationError(
"Password must contain at least one lowercase letter"
)

# Check if password contains at least one uppercase letter
if not re.search(r"[A-Z]", password):
raise ValidationError(
"Password must contain at least one uppercase letter"
)

# Check if password contains at least one digit
if not re.search(r"[0-9]", password):
raise ValidationError("Password must contain at least one digit")

# Check if password contains at least one special character
if not re.search(r"[~`!@#$%^&*()_+\-={[}\]|:;\"'<,>.?/]", password):
raise ValidationError(
"Password must contain at least one special character"
)

return True

# Schema validation
schema = {
"type": "object",
"required": ["email_address", "password"],
"additionalProperties": False,
"properties": {
"email_address": {"type": "string", "format": "valid_email"},
"password": {
"type": "string",
"format": "password",
},
},
}

format_checker = FormatChecker()
format_checker.checks("valid_email")(validate_is_valid_email)
format_checker.checks("password")(validate_password)

try:
validate(instance=data, schema=schema, format_checker=format_checker)
except ValidationError as e:
return e.message, 400

user = model.User.query.filter_by(
email_address=data["email_address"]
).one_or_none()
Expand Down Expand Up @@ -80,8 +143,40 @@ def post(self):

email_address = data["email_address"]

user = model.User.query.filter_by(email_address=email_address).one_or_none()
def validate_is_valid_email(instance):
print("within is_valid_email")
email_address = instance
print(email_address)
try:
validate_email(email_address)
return True
except EmailNotValidError as e:
raise ValidationError("Invalid email address format") from e

# Schema validation
schema = {
"type": "object",
"required": ["email_address", "password"],
"additionalProperties": False,
"properties": {
"email_address": {
"type": "string",
"format": "valid email",
"error_message": "Invalid email address",
},
"password": {"type": "string", "minLength": 8},
},
}

format_checker = FormatChecker()
format_checker.checks("valid email")(validate_is_valid_email)

try:
validate(instance=data, schema=schema, format_checker=format_checker)
except ValidationError as e:
return e.message, 400

user = model.User.query.filter_by(email_address=email_address).one_or_none()
if not user:
return "Invalid credentials", 401

Expand Down
2 changes: 1 addition & 1 deletion apis/dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def put(self, study_id: int, dataset_id: int):
study = model.Study.query.get(study_id)
if not is_granted("update_dataset", study):
return "Access denied, you can not modify", 403
data = request.json
data: typing.Union[dict, typing.Any] = request.json
data_obj = model.Dataset.query.get(dataset_id)
data_obj.update(data)
model.db.session.commit()
Expand Down
4 changes: 3 additions & 1 deletion apis/dataset_metadata/dataset_consent.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import typing

from flask import request
from flask_restx import Resource, fields

Expand Down Expand Up @@ -31,7 +33,7 @@ def get(self, study_id: int, dataset_id: int):
return [d.to_dict() for d in dataset_consent_]

def put(self, study_id: int, dataset_id: int):
data = request.json
data: typing.Union[dict, typing.Any] = request.json
dataset_ = model.Dataset.query.get(dataset_id)
dataset_consent_ = dataset_.dataset_consent.update(data)
model.db.session.commit()
Expand Down
4 changes: 3 additions & 1 deletion apis/dataset_metadata/dataset_date.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import typing

from flask import request
from flask_restx import Resource, fields

Expand Down Expand Up @@ -27,7 +29,7 @@ def get(self, study_id: int, dataset_id: int):
return [d.to_dict() for d in dataset_date_]

def put(self, study_id: int, dataset_id: int):
data = request.json
data: typing.Union[dict, typing.Any] = request.json
dataset_ = model.Dataset.query.get(dataset_id)
dataset_date_ = dataset_.dataset_date.update(data)
model.db.session.commit()
Expand Down
4 changes: 3 additions & 1 deletion apis/dataset_metadata/dataset_de_ident_level.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import typing

from flask import request
from flask_restx import Resource, fields

Expand Down Expand Up @@ -31,7 +33,7 @@ def get(self, study_id: int, dataset_id: int):
return [d.to_dict() for d in de_ident_level_]

def put(self, study_id: int, dataset_id: int):
data = request.json
data: typing.Union[dict, typing.Any] = request.json
dataset_ = model.Dataset.query.get(dataset_id)
de_ident_level_ = dataset_.dataset_de_ident_level.update(data)
model.db.session.commit()
Expand Down
4 changes: 3 additions & 1 deletion apis/dataset_metadata/dataset_managing_organization.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import typing

from flask import request
from flask_restx import Resource, fields

Expand Down Expand Up @@ -26,7 +28,7 @@ def get(self, study_id: int, dataset_id: int):
return [d.to_dict() for d in managing_organization_]

def put(self, study_id: int, dataset_id: int):
data = request.json
data: typing.Union[dict, typing.Any] = request.json
dataset_ = model.Dataset.query.get(dataset_id)
managing_organization_ = dataset_.dataset_managing_organization.update(data)
model.db.session.commit()
Expand Down
4 changes: 3 additions & 1 deletion apis/dataset_metadata/dataset_other.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import typing

from flask import request
from flask_restx import Resource, fields

Expand Down Expand Up @@ -30,7 +32,7 @@ def get(self, study_id: int, dataset_id: int):
return [d.to_dict() for d in dataset_other_]

def put(self, study_id: int, dataset_id: int):
data = request.json
data: typing.Union[dict, typing.Any] = request.json
dataset_ = model.Dataset.query.get(dataset_id)
dataset_other_ = dataset_.dataset_other.update(data)
model.db.session.commit()
Expand Down
4 changes: 3 additions & 1 deletion apis/dataset_metadata/dataset_readme.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import typing

from flask import request
from flask_restx import Resource, fields

Expand All @@ -22,7 +24,7 @@ def get(self, study_id: int, dataset_id: int):
return [d.to_dict() for d in dataset_readme_]

def put(self, study_id: int, dataset_id: int):
data = request.json
data: typing.Union[dict, typing.Any] = request.json
dataset_ = model.Dataset.query.get(dataset_id)
dataset_readme_ = dataset_.dataset_readme.update(data)
model.db.session.commit()
Expand Down
4 changes: 3 additions & 1 deletion apis/dataset_metadata/dataset_record_keys.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import typing

from flask import request
from flask_restx import Resource, fields

Expand Down Expand Up @@ -26,7 +28,7 @@ def get(self, study_id: int, dataset_id: int):
return [d.to_dict() for d in dataset_record_keys_]

def put(self, study_id: int, dataset_id: int):
data = request.json
data: typing.Union[dict, typing.Any] = request.json
dataset_ = model.Dataset.query.get(dataset_id)
dataset_record_keys_ = dataset_.dataset_de_ident_level.update(data)
model.db.session.commit()
Expand Down
2 changes: 1 addition & 1 deletion apis/dataset_metadata/dataset_related_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def post(self, study_id: int, dataset_id: int):
)
class DatasetRelatedItemUpdate(Resource):
def put(self, study_id: int, dataset_id: int, related_item_id: int):
data = request.json
data: Union[Any, dict] = request.json
dataset_related_item_ = model.DatasetRelatedItem.query.get(related_item_id)
dataset_related_item_.update(data)
model.db.session.commit()
Expand Down
36 changes: 36 additions & 0 deletions apis/study.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from flask import g, request
from flask_restx import Namespace, Resource, fields, reqparse
from jsonschema import ValidationError, validate

import model

Expand Down Expand Up @@ -55,7 +56,25 @@ def get(self):
@api.response(200, "Success")
@api.response(400, "Validation Error")
def post(self):
"""Create a new study"""
# Schema validation
schema = {
"type": "object",
"required": ["title", "image"],
"additionalProperties": False,
"properties": {
"title": {"type": "string", "minLength": 1},
"image": {"type": "string", "minLength": 1},
},
}

try:
validate(request.json, schema)
except ValidationError as e:
return e.message, 400

data: Union[Any, dict] = request.json

add_study = model.Study.from_data(data)
model.db.session.add(add_study)
study_id = add_study.id
Expand All @@ -81,6 +100,23 @@ def get(self, study_id: int):
@api.response(400, "Validation Error")
@api.doc(description="Update a study's details")
def put(self, study_id: int):
"""Update a study"""
# Schema validation
schema = {
"type": "object",
"required": ["title", "image"],
"additionalProperties": False,
"properties": {
"title": {"type": "string", "minLength": 1},
"image": {"type": "string", "minLength": 1},
},
}

try:
validate(request.json, schema)
except ValidationError as e:
return e.message, 400

update_study = model.Study.query.get(study_id)
if not is_granted("update_study", update_study):
return "Access denied, you can not modify", 403
Expand Down
38 changes: 38 additions & 0 deletions apis/study_metadata/study_arm.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from flask import request
from flask_restx import Resource, fields
from jsonschema import ValidationError, validate

import model
from apis.study_metadata_namespace import api
Expand Down Expand Up @@ -46,6 +47,43 @@ def get(self, study_id):

def post(self, study_id):
"""Create study arm metadata"""
# Schema validation
schema = {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"label": {"type": "string", "minLength": 1},
"type": {
"type": "string",
"enum": [
"Experimental",
"Active Comparator",
"Placebo Comparator",
"Sham Comparator",
"No Intervention",
"Other",
],
},
"description": {"type": "string", "minLength": 1},
"intervention_list": {
"type": "array",
"items": {"type": "string", "minLength": 1},
"minItems": 1,
"uniqueItems": True,
},
},
"required": ["label", "type", "description", "intervention_list"],
},
"uniqueItems": True,
}

try:
validate(request.json, schema)
except ValidationError as e:
return e.message, 400

study: model.Study = model.Study.query.get(study_id)
if not is_granted("study_metadata", study):
return "Access denied, you can not delete study", 403
Expand Down
Loading

0 comments on commit 5386ce2

Please sign in to comment.