Skip to content

Commit

Permalink
Validate with official schema, sanitize identifiers properly, panic o…
Browse files Browse the repository at this point in the history
…n duplicate operationId (#39)

* validation schema + sanitize all identifiers

* panic on duplicate operationIds

* renamed sanitized_operation_ids() to sanitize_operation_ids_and_check_duplicate

* added LICENSE and NOTICE for validator_schema

* revert spec

---------

Co-authored-by: Stanislav Kosorin <[email protected]>
Co-authored-by: Felix <[email protected]>
  • Loading branch information
3 people authored May 31, 2023
1 parent 52d0d3a commit 16143a0
Show file tree
Hide file tree
Showing 14 changed files with 5,416 additions and 42 deletions.
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ gtmpl_value = "0.5.1"
regex = "1.8.1"
Inflector = "0.11.4"
clap = {version = "4.3.0", features = ["derive"]}
jsonschema = "0.17.0"
proc-macro2 = "1.0.59"

4 changes: 2 additions & 2 deletions example/specs/basic.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ channels:
payload:
type: object
properties:
name:
userSingnedUp:
type: string
publish:
operationId: userSingedUp
operationId: userSignedUp
summary: send welcome email to user
message:
payload:
Expand Down
50 changes: 50 additions & 0 deletions example/specs/invalid-names.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
asyncapi: 2.1.0
info:
title: My_API
version: 1.0.0
servers:
production:
url: demo.nats.io
protocol: nats
channels:
user/signedup:
subscribe:
operationId: onUserSignup.l;/.,;';.,\n'
summary: User signup notification
message:
payload:
type: object
properties:
userSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUp:
type: string
publish:
operationId: userSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUp
summary: send welcome email to user
message:
payload:
type: string

user/signedupd:
subscribe:
operationId: onUserSignup.l/.,;';.,\nfdsfsd
summary: User signup notification
message:
payload:
type: object
properties:
userSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUp:
type: string
publish:
operationId: userSing\edUpuserSing\edUpudserSing\edUpuserSfdsing\edfdsUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUp
summary: send welcome email to user
message:
payload:
type: string
user/buy:
subscribe:
operationId: userBought
summary: User bought something
message:
payload:
type: string

3 changes: 2 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ fn main() {
println!("specfile_path: {:?}", specfile_path);

let template_path = Path::new("./templates/");
let validator_schema_path = Path::new("./validator_schema/2.1.0.json");

let spec = parser::parse_spec_to_model(specfile_path).unwrap();
let spec = parser::parse_spec_to_model(specfile_path, validator_schema_path).unwrap();
println!("{:?}", spec);

let title = match args.project_title {
Expand Down
70 changes: 51 additions & 19 deletions src/parser/common.rs
Original file line number Diff line number Diff line change
@@ -1,30 +1,57 @@
use std::{fs, path::Path};
use std::{collections::HashSet, fs, path::Path};

use inflector::Inflector;
use proc_macro2::Ident;
use regex::Regex;

use crate::asyncapi_model::AsyncAPI;

pub fn parse_spec_to_model(path: &Path) -> Result<AsyncAPI, serde_json::Error> {
let string_content = fs::read_to_string(path).expect("file could not be read");
use super::{
preprocessor::{resolve_refs, sanitize_operation_ids_and_check_duplicate},
validator::validate_asyncapi_schema,
};

pub fn parse_spec_to_model(
spec_path: &Path,
validator_schema_path: &Path,
) -> Result<AsyncAPI, serde_json::Error> {
let spec = parse_string_to_serde_json_value(spec_path);
let validator = parse_string_to_serde_json_value(validator_schema_path);

validate_asyncapi_schema(&validator, &spec);

let preprocessed_spec = preprocess_schema(spec);
let spec = serde_json::from_value::<AsyncAPI>(preprocessed_spec)?;
Ok(spec)
}

fn preprocess_schema(spec: serde_json::Value) -> serde_json::Value {
let resolved_refs = resolve_refs(spec.clone(), spec);
let mut seen = HashSet::new();
let sanitized =
sanitize_operation_ids_and_check_duplicate(resolved_refs.clone(), resolved_refs, &mut seen);
println!("Preprocessed spec: {}", sanitized);
sanitized
}

fn parse_string_to_serde_json_value(file_path: &Path) -> serde_json::Value {
let file_string = fs::read_to_string(file_path).expect("File could not be read");
// check if file is yaml or json
let parsed = match path.extension() {
let parsed_value = match file_path.extension() {
Some(ext) => match ext.to_str() {
Some("yaml") => serde_yaml::from_str::<serde_json::Value>(&string_content).unwrap(),
Some("yml") => serde_yaml::from_str::<serde_json::Value>(&string_content).unwrap(),
Some("json") => serde_json::from_str::<serde_json::Value>(&string_content).unwrap(),
Some("yaml") | Some("yml") => {
serde_yaml::from_str::<serde_json::Value>(&file_string).unwrap()
}
Some("json") => serde_json::from_str::<serde_json::Value>(&file_string).unwrap(),
_ => {
panic!("file has no extension");
panic!("File has an unsupported extension");
}
},
None => {
panic!("file has no extension");
panic!("File has no extension");
}
};
let with_resolved_references =
crate::parser::resolve_refs::resolve_refs(parsed.clone(), parsed);
let spec = serde_json::from_value::<AsyncAPI>(with_resolved_references)?;
Ok(spec)
parsed_value
}

fn capitalize_first_char(s: &str) -> String {
Expand All @@ -35,11 +62,16 @@ fn capitalize_first_char(s: &str) -> String {
}
}

pub fn convert_string_to_valid_type_name(s: &str, suffix: &str) -> String {
let re = Regex::new(r"[^\w\s]").unwrap();
pub fn validate_identifier_string(s: &str) -> String {
// Remove special chars, capitalize words, remove spaces
let mut root_msg_name = re.replace_all(s, " ").to_title_case().replace(' ', "");
// Append Message to the end of the name
root_msg_name.push_str(suffix);
capitalize_first_char(root_msg_name.as_str())
let re = Regex::new(r"[^\w\s]").unwrap();
let sanitized_identifier = re.replace_all(s, " ").to_title_case().replace(' ', "");
let capitalized_sanitized_identifier = capitalize_first_char(sanitized_identifier.as_str());
// Create a new identifier
// This acts as validation for the message name, panics when the name is invalid
Ident::new(
&capitalized_sanitized_identifier,
proc_macro2::Span::call_site(),
);
capitalized_sanitized_identifier
}
5 changes: 2 additions & 3 deletions src/parser/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
mod common;
mod preprocessor;
mod pubsub;
mod resolve_refs;
mod schema_parser;
mod validator;
pub use common::parse_spec_to_model;
pub use pubsub::spec_to_pubsub_template_type;
pub use resolve_refs::resolve_refs;
pub use schema_parser::schema_parser_mapper;
55 changes: 55 additions & 0 deletions src/parser/resolve_refs.rs → src/parser/preprocessor.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
use crate::parser::common::validate_identifier_string;
use serde_json::json;
use std::collections::HashSet;

pub fn resolve_json_path(json: serde_json::Value, path: &str) -> serde_json::Value {
let parts = path.split('/').collect::<Vec<&str>>();
let mut current_json = json;
Expand All @@ -7,6 +11,57 @@ pub fn resolve_json_path(json: serde_json::Value, path: &str) -> serde_json::Val
current_json
}

pub fn sanitize_operation_ids_and_check_duplicate(
json: serde_json::Value,
root_json: serde_json::Value,
seen_operation_ids: &mut HashSet<String>,
) -> serde_json::Value {
match json {
serde_json::Value::Object(map) => {
let mut new_map = serde_json::Map::new();
for (key, value) in map {
if key == "operationId" {
if let serde_json::Value::String(string_val) = &value {
let sanitized_val = validate_identifier_string(string_val.as_str());
if seen_operation_ids.contains(&sanitized_val) {
panic!("Duplicate operationId found: {}", sanitized_val);
} else {
seen_operation_ids.insert(sanitized_val.clone());
new_map.insert(key, json!(sanitized_val));
}
} else {
panic!("operationId value is not a string");
}
} else {
new_map.insert(
key,
sanitize_operation_ids_and_check_duplicate(
value,
root_json.clone(),
seen_operation_ids,
),
);
}
}
serde_json::Value::Object(new_map)
}
serde_json::Value::Array(array) => {
let new_array = array
.into_iter()
.map(|value| {
sanitize_operation_ids_and_check_duplicate(
value,
root_json.clone(),
seen_operation_ids,
)
})
.collect();
serde_json::Value::Array(new_array)
}
_ => json,
}
}

pub fn resolve_refs(json: serde_json::Value, root_json: serde_json::Value) -> serde_json::Value {
match json {
serde_json::Value::Object(map) => {
Expand Down
7 changes: 3 additions & 4 deletions src/parser/pubsub.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use super::{schema_parser::SchemaParserError, schema_parser_mapper};
use super::schema_parser::{schema_parser_mapper, SchemaParserError};
use crate::{
asyncapi_model::{AsyncAPI, OperationMessageType, Payload, ReferenceOr, Schema},
parser::common::convert_string_to_valid_type_name,
parser::common::validate_identifier_string,
template_model::PubsubTemplate,
};
use std::{collections::HashMap, io};
Expand Down Expand Up @@ -37,7 +37,6 @@ fn parse_single_message_operation_type(
match message_ref_or_item {
ReferenceOr::Item(message) => match &message.payload {
Some(Payload::Schema(schema)) => {
println!("\nmap schema: {:?}", schema);
transform_schema_to_string_vec(schema, &root_msg_name).unwrap()
}
Some(Payload::Any(val)) => {
Expand All @@ -64,7 +63,7 @@ fn extract_schemas_from_asyncapi(spec: &AsyncAPI) -> Vec<String> {
return channels_ops
.iter()
.flat_map(|x| {
let root_msg_name = convert_string_to_valid_type_name(x.0, "");
let root_msg_name = validate_identifier_string(x.0);
let channel = x.1;
let operation_message = channel.message.as_ref().unwrap();
match operation_message {
Expand Down
21 changes: 8 additions & 13 deletions src/parser/schema_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::asyncapi_model::{
use core::fmt;
use std::{collections::HashMap, format};

use super::common::convert_string_to_valid_type_name;
use super::common::validate_identifier_string;

#[derive(Debug, Clone)]
pub enum SchemaParserError {
Expand Down Expand Up @@ -38,7 +38,7 @@ fn object_schema_to_string(
) -> Result<String, SchemaParserError> {
let before_string = format!(
"#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct {} {{\n",
convert_string_to_valid_type_name(property_name, "")
validate_identifier_string(property_name)
);
let after_string = String::from("\n}\n");
let property_string_iterator: Vec<Result<String, SchemaParserError>> = schema
Expand Down Expand Up @@ -68,21 +68,16 @@ fn object_schema_to_string(
Ok(property_name.to_string())
}

fn sanitize_property_name(property_name: &str) -> String {
// TODO: do proper sanitization so that the property name is a valid rust identifier
property_name.replace('-', "_")
}

fn primitive_type_to_string(
schema_type: Type,
property_name: &str,
) -> Result<String, SchemaParserError> {
// TODO: Add support for arrays
match schema_type {
Type::String(_var) => Ok(format!("pub {}: String", sanitize_property_name(property_name))),
Type::Number(_var) => Ok(format!("pub {}: f64", sanitize_property_name(property_name))),
Type::Integer(_var) => Ok(format!("pub {}: int64", sanitize_property_name(property_name)) ),
Type::Boolean{} => Ok(format!("pub {}: bool", sanitize_property_name(property_name))),
Type::String(_var) => Ok(format!("pub {}: String", validate_identifier_string(property_name))),
Type::Number(_var) => Ok(format!("pub {}: f64", validate_identifier_string(property_name))),
Type::Integer(_var) => Ok(format!("pub {}: int64", validate_identifier_string(property_name)) ),
Type::Boolean{} => Ok(format!("pub {}: bool", validate_identifier_string(property_name))),
_type => Err(SchemaParserError::GenericError("Unsupported primitive type: Currently only supports string, number, integer and boolean types".to_string(), Some(property_name.into()))),
}
}
Expand All @@ -99,8 +94,8 @@ pub fn schema_parser_mapper(
let struct_name = object_schema_to_string(y, property_name, all_structs)?;
Ok(format!(
"pub {}: {}",
property_name,
convert_string_to_valid_type_name(struct_name.as_str(), "").as_str()
struct_name,
validate_identifier_string(struct_name.as_str()).as_str()
))
}
_primitive_type => primitive_type_to_string(_primitive_type.clone(), property_name),
Expand Down
15 changes: 15 additions & 0 deletions src/parser/validator.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
use jsonschema::JSONSchema;

pub fn validate_asyncapi_schema(validator: &serde_json::Value, instance: &serde_json::Value) {
let compiled = JSONSchema::compile(validator).expect("A valid schema");
let result = compiled.validate(instance);
if let Err(errors) = result {
for error in errors {
println!("Validation error: {}", error);
println!("Instance path: {}", error.instance_path);
}
panic!("Validation failed");
} else {
println!("Validation succeeded");
}
}
Loading

0 comments on commit 16143a0

Please sign in to comment.