Skip to content

Commit 16143a0

Browse files
stano45bird-dancer
andauthored
Validate with official schema, sanitize identifiers properly, panic on 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]>
1 parent 52d0d3a commit 16143a0

File tree

14 files changed

+5416
-42
lines changed

14 files changed

+5416
-42
lines changed

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,6 @@ gtmpl_value = "0.5.1"
1717
regex = "1.8.1"
1818
Inflector = "0.11.4"
1919
clap = {version = "4.3.0", features = ["derive"]}
20+
jsonschema = "0.17.0"
21+
proc-macro2 = "1.0.59"
2022

example/specs/basic.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ channels:
1515
payload:
1616
type: object
1717
properties:
18-
name:
18+
userSingnedUp:
1919
type: string
2020
publish:
21-
operationId: userSingedUp
21+
operationId: userSignedUp
2222
summary: send welcome email to user
2323
message:
2424
payload:

example/specs/invalid-names.yaml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
asyncapi: 2.1.0
2+
info:
3+
title: My_API
4+
version: 1.0.0
5+
servers:
6+
production:
7+
url: demo.nats.io
8+
protocol: nats
9+
channels:
10+
user/signedup:
11+
subscribe:
12+
operationId: onUserSignup.l;/.,;';.,\n'
13+
summary: User signup notification
14+
message:
15+
payload:
16+
type: object
17+
properties:
18+
userSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUp:
19+
type: string
20+
publish:
21+
operationId: userSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUp
22+
summary: send welcome email to user
23+
message:
24+
payload:
25+
type: string
26+
27+
user/signedupd:
28+
subscribe:
29+
operationId: onUserSignup.l/.,;';.,\nfdsfsd
30+
summary: User signup notification
31+
message:
32+
payload:
33+
type: object
34+
properties:
35+
userSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUp:
36+
type: string
37+
publish:
38+
operationId: userSing\edUpuserSing\edUpudserSing\edUpuserSfdsing\edfdsUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUpuserSing\edUp
39+
summary: send welcome email to user
40+
message:
41+
payload:
42+
type: string
43+
user/buy:
44+
subscribe:
45+
operationId: userBought
46+
summary: User bought something
47+
message:
48+
payload:
49+
type: string
50+

src/main.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ fn main() {
1717
println!("specfile_path: {:?}", specfile_path);
1818

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

21-
let spec = parser::parse_spec_to_model(specfile_path).unwrap();
22+
let spec = parser::parse_spec_to_model(specfile_path, validator_schema_path).unwrap();
2223
println!("{:?}", spec);
2324

2425
let title = match args.project_title {

src/parser/common.rs

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,57 @@
1-
use std::{fs, path::Path};
1+
use std::{collections::HashSet, fs, path::Path};
22

33
use inflector::Inflector;
4+
use proc_macro2::Ident;
45
use regex::Regex;
56

67
use crate::asyncapi_model::AsyncAPI;
78

8-
pub fn parse_spec_to_model(path: &Path) -> Result<AsyncAPI, serde_json::Error> {
9-
let string_content = fs::read_to_string(path).expect("file could not be read");
9+
use super::{
10+
preprocessor::{resolve_refs, sanitize_operation_ids_and_check_duplicate},
11+
validator::validate_asyncapi_schema,
12+
};
13+
14+
pub fn parse_spec_to_model(
15+
spec_path: &Path,
16+
validator_schema_path: &Path,
17+
) -> Result<AsyncAPI, serde_json::Error> {
18+
let spec = parse_string_to_serde_json_value(spec_path);
19+
let validator = parse_string_to_serde_json_value(validator_schema_path);
20+
21+
validate_asyncapi_schema(&validator, &spec);
22+
23+
let preprocessed_spec = preprocess_schema(spec);
24+
let spec = serde_json::from_value::<AsyncAPI>(preprocessed_spec)?;
25+
Ok(spec)
26+
}
27+
28+
fn preprocess_schema(spec: serde_json::Value) -> serde_json::Value {
29+
let resolved_refs = resolve_refs(spec.clone(), spec);
30+
let mut seen = HashSet::new();
31+
let sanitized =
32+
sanitize_operation_ids_and_check_duplicate(resolved_refs.clone(), resolved_refs, &mut seen);
33+
println!("Preprocessed spec: {}", sanitized);
34+
sanitized
35+
}
36+
37+
fn parse_string_to_serde_json_value(file_path: &Path) -> serde_json::Value {
38+
let file_string = fs::read_to_string(file_path).expect("File could not be read");
1039
// check if file is yaml or json
11-
let parsed = match path.extension() {
40+
let parsed_value = match file_path.extension() {
1241
Some(ext) => match ext.to_str() {
13-
Some("yaml") => serde_yaml::from_str::<serde_json::Value>(&string_content).unwrap(),
14-
Some("yml") => serde_yaml::from_str::<serde_json::Value>(&string_content).unwrap(),
15-
Some("json") => serde_json::from_str::<serde_json::Value>(&string_content).unwrap(),
42+
Some("yaml") | Some("yml") => {
43+
serde_yaml::from_str::<serde_json::Value>(&file_string).unwrap()
44+
}
45+
Some("json") => serde_json::from_str::<serde_json::Value>(&file_string).unwrap(),
1646
_ => {
17-
panic!("file has no extension");
47+
panic!("File has an unsupported extension");
1848
}
1949
},
2050
None => {
21-
panic!("file has no extension");
51+
panic!("File has no extension");
2252
}
2353
};
24-
let with_resolved_references =
25-
crate::parser::resolve_refs::resolve_refs(parsed.clone(), parsed);
26-
let spec = serde_json::from_value::<AsyncAPI>(with_resolved_references)?;
27-
Ok(spec)
54+
parsed_value
2855
}
2956

3057
fn capitalize_first_char(s: &str) -> String {
@@ -35,11 +62,16 @@ fn capitalize_first_char(s: &str) -> String {
3562
}
3663
}
3764

38-
pub fn convert_string_to_valid_type_name(s: &str, suffix: &str) -> String {
39-
let re = Regex::new(r"[^\w\s]").unwrap();
65+
pub fn validate_identifier_string(s: &str) -> String {
4066
// Remove special chars, capitalize words, remove spaces
41-
let mut root_msg_name = re.replace_all(s, " ").to_title_case().replace(' ', "");
42-
// Append Message to the end of the name
43-
root_msg_name.push_str(suffix);
44-
capitalize_first_char(root_msg_name.as_str())
67+
let re = Regex::new(r"[^\w\s]").unwrap();
68+
let sanitized_identifier = re.replace_all(s, " ").to_title_case().replace(' ', "");
69+
let capitalized_sanitized_identifier = capitalize_first_char(sanitized_identifier.as_str());
70+
// Create a new identifier
71+
// This acts as validation for the message name, panics when the name is invalid
72+
Ident::new(
73+
&capitalized_sanitized_identifier,
74+
proc_macro2::Span::call_site(),
75+
);
76+
capitalized_sanitized_identifier
4577
}

src/parser/mod.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
mod common;
2+
mod preprocessor;
23
mod pubsub;
3-
mod resolve_refs;
44
mod schema_parser;
5+
mod validator;
56
pub use common::parse_spec_to_model;
67
pub use pubsub::spec_to_pubsub_template_type;
7-
pub use resolve_refs::resolve_refs;
8-
pub use schema_parser::schema_parser_mapper;

src/parser/resolve_refs.rs renamed to src/parser/preprocessor.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
use crate::parser::common::validate_identifier_string;
2+
use serde_json::json;
3+
use std::collections::HashSet;
4+
15
pub fn resolve_json_path(json: serde_json::Value, path: &str) -> serde_json::Value {
26
let parts = path.split('/').collect::<Vec<&str>>();
37
let mut current_json = json;
@@ -7,6 +11,57 @@ pub fn resolve_json_path(json: serde_json::Value, path: &str) -> serde_json::Val
711
current_json
812
}
913

14+
pub fn sanitize_operation_ids_and_check_duplicate(
15+
json: serde_json::Value,
16+
root_json: serde_json::Value,
17+
seen_operation_ids: &mut HashSet<String>,
18+
) -> serde_json::Value {
19+
match json {
20+
serde_json::Value::Object(map) => {
21+
let mut new_map = serde_json::Map::new();
22+
for (key, value) in map {
23+
if key == "operationId" {
24+
if let serde_json::Value::String(string_val) = &value {
25+
let sanitized_val = validate_identifier_string(string_val.as_str());
26+
if seen_operation_ids.contains(&sanitized_val) {
27+
panic!("Duplicate operationId found: {}", sanitized_val);
28+
} else {
29+
seen_operation_ids.insert(sanitized_val.clone());
30+
new_map.insert(key, json!(sanitized_val));
31+
}
32+
} else {
33+
panic!("operationId value is not a string");
34+
}
35+
} else {
36+
new_map.insert(
37+
key,
38+
sanitize_operation_ids_and_check_duplicate(
39+
value,
40+
root_json.clone(),
41+
seen_operation_ids,
42+
),
43+
);
44+
}
45+
}
46+
serde_json::Value::Object(new_map)
47+
}
48+
serde_json::Value::Array(array) => {
49+
let new_array = array
50+
.into_iter()
51+
.map(|value| {
52+
sanitize_operation_ids_and_check_duplicate(
53+
value,
54+
root_json.clone(),
55+
seen_operation_ids,
56+
)
57+
})
58+
.collect();
59+
serde_json::Value::Array(new_array)
60+
}
61+
_ => json,
62+
}
63+
}
64+
1065
pub fn resolve_refs(json: serde_json::Value, root_json: serde_json::Value) -> serde_json::Value {
1166
match json {
1267
serde_json::Value::Object(map) => {

src/parser/pubsub.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
use super::{schema_parser::SchemaParserError, schema_parser_mapper};
1+
use super::schema_parser::{schema_parser_mapper, SchemaParserError};
22
use crate::{
33
asyncapi_model::{AsyncAPI, OperationMessageType, Payload, ReferenceOr, Schema},
4-
parser::common::convert_string_to_valid_type_name,
4+
parser::common::validate_identifier_string,
55
template_model::PubsubTemplate,
66
};
77
use std::{collections::HashMap, io};
@@ -37,7 +37,6 @@ fn parse_single_message_operation_type(
3737
match message_ref_or_item {
3838
ReferenceOr::Item(message) => match &message.payload {
3939
Some(Payload::Schema(schema)) => {
40-
println!("\nmap schema: {:?}", schema);
4140
transform_schema_to_string_vec(schema, &root_msg_name).unwrap()
4241
}
4342
Some(Payload::Any(val)) => {
@@ -64,7 +63,7 @@ fn extract_schemas_from_asyncapi(spec: &AsyncAPI) -> Vec<String> {
6463
return channels_ops
6564
.iter()
6665
.flat_map(|x| {
67-
let root_msg_name = convert_string_to_valid_type_name(x.0, "");
66+
let root_msg_name = validate_identifier_string(x.0);
6867
let channel = x.1;
6968
let operation_message = channel.message.as_ref().unwrap();
7069
match operation_message {

src/parser/schema_parser.rs

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use crate::asyncapi_model::{
55
use core::fmt;
66
use std::{collections::HashMap, format};
77

8-
use super::common::convert_string_to_valid_type_name;
8+
use super::common::validate_identifier_string;
99

1010
#[derive(Debug, Clone)]
1111
pub enum SchemaParserError {
@@ -38,7 +38,7 @@ fn object_schema_to_string(
3838
) -> Result<String, SchemaParserError> {
3939
let before_string = format!(
4040
"#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct {} {{\n",
41-
convert_string_to_valid_type_name(property_name, "")
41+
validate_identifier_string(property_name)
4242
);
4343
let after_string = String::from("\n}\n");
4444
let property_string_iterator: Vec<Result<String, SchemaParserError>> = schema
@@ -68,21 +68,16 @@ fn object_schema_to_string(
6868
Ok(property_name.to_string())
6969
}
7070

71-
fn sanitize_property_name(property_name: &str) -> String {
72-
// TODO: do proper sanitization so that the property name is a valid rust identifier
73-
property_name.replace('-', "_")
74-
}
75-
7671
fn primitive_type_to_string(
7772
schema_type: Type,
7873
property_name: &str,
7974
) -> Result<String, SchemaParserError> {
8075
// TODO: Add support for arrays
8176
match schema_type {
82-
Type::String(_var) => Ok(format!("pub {}: String", sanitize_property_name(property_name))),
83-
Type::Number(_var) => Ok(format!("pub {}: f64", sanitize_property_name(property_name))),
84-
Type::Integer(_var) => Ok(format!("pub {}: int64", sanitize_property_name(property_name)) ),
85-
Type::Boolean{} => Ok(format!("pub {}: bool", sanitize_property_name(property_name))),
77+
Type::String(_var) => Ok(format!("pub {}: String", validate_identifier_string(property_name))),
78+
Type::Number(_var) => Ok(format!("pub {}: f64", validate_identifier_string(property_name))),
79+
Type::Integer(_var) => Ok(format!("pub {}: int64", validate_identifier_string(property_name)) ),
80+
Type::Boolean{} => Ok(format!("pub {}: bool", validate_identifier_string(property_name))),
8681
_type => Err(SchemaParserError::GenericError("Unsupported primitive type: Currently only supports string, number, integer and boolean types".to_string(), Some(property_name.into()))),
8782
}
8883
}
@@ -99,8 +94,8 @@ pub fn schema_parser_mapper(
9994
let struct_name = object_schema_to_string(y, property_name, all_structs)?;
10095
Ok(format!(
10196
"pub {}: {}",
102-
property_name,
103-
convert_string_to_valid_type_name(struct_name.as_str(), "").as_str()
97+
struct_name,
98+
validate_identifier_string(struct_name.as_str()).as_str()
10499
))
105100
}
106101
_primitive_type => primitive_type_to_string(_primitive_type.clone(), property_name),

src/parser/validator.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
use jsonschema::JSONSchema;
2+
3+
pub fn validate_asyncapi_schema(validator: &serde_json::Value, instance: &serde_json::Value) {
4+
let compiled = JSONSchema::compile(validator).expect("A valid schema");
5+
let result = compiled.validate(instance);
6+
if let Err(errors) = result {
7+
for error in errors {
8+
println!("Validation error: {}", error);
9+
println!("Instance path: {}", error.instance_path);
10+
}
11+
panic!("Validation failed");
12+
} else {
13+
println!("Validation succeeded");
14+
}
15+
}

0 commit comments

Comments
 (0)