diff --git a/src/Microsoft.Health.Fhir.SpecManager/Language/ZodValidator.cs b/src/Microsoft.Health.Fhir.SpecManager/Language/ZodValidator.cs new file mode 100644 index 000000000..11f554d51 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SpecManager/Language/ZodValidator.cs @@ -0,0 +1,587 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml.Linq; +using Microsoft.Health.Fhir.CodeGenCommon.Extensions; +using Microsoft.Health.Fhir.SpecManager.Manager; +using Microsoft.Health.Fhir.SpecManager.Models; + +namespace Microsoft.Health.Fhir.SpecManager.Language +{ + /// Export to TypeScript - serializable to/from JSON. + public sealed class ZodValidator : ILanguage + { + /// The systems named by display. + private static HashSet _systemsNamedByDisplay = new HashSet() + { + /// Units of Measure have incomprehensible codes after naming substitutions. + "http://unitsofmeasure.org", + }; + + private static HashSet _systemsNamedByCode = new HashSet() + { + /// Operation Outcomes include c-style string formats in display. + "http://terminology.hl7.org/CodeSystem/operation-outcome", + + /// Descriptions have quoted values. + "http://terminology.hl7.org/CodeSystem/smart-capabilities", + + /// Descriptions have quoted values. + "http://hl7.org/fhir/v2/0301", + + /// Display values are too long to be useful. + "http://terminology.hl7.org/CodeSystem/v2-0178", + + /// Display values are too long to be useful. + "http://terminology.hl7.org/CodeSystem/v2-0277", + + /// Display values are too long to be useful. + "http://terminology.hl7.org/CodeSystem/v3-VaccineManufacturer", + + /// Display values are too long to be useful. + "http://hl7.org/fhir/v2/0278", + + /// Display includes operation symbols: $. + "http://terminology.hl7.org/CodeSystem/testscript-operation-codes", + + /// Display are often just symbols. + "http://hl7.org/fhir/v2/0290", + + /// Display includes too many Unicode characters (invalid export names). + "http://hl7.org/fhir/v2/0255", + + /// Display includes too many Unicode characters (invalid export names). + "http://hl7.org/fhir/v2/0256", + }; + + /// FHIR information we are exporting. + private FhirVersionInfo _info; + + /// Options for controlling the export. + private ExporterOptions _options; + + /// + /// True if we should write a namespace directive + /// + private bool _includeNamespace = false; + + /// + /// The namespace to use. + /// + private string _namespace = string.Empty; + + /// The exported codes. + private HashSet _exportedCodes = new HashSet(); + + /// The exported resources. + private List _exportedResources = new List(); + + /// The currently in-use text writer. + private ExportStreamWriter _writer; + + /// Name of the language. + private const string _languageName = "Zod"; + + /// The single file export extension. + private const string _singleFileExportExtension = ".ts"; + + /// The minimum type script version. + private string _minimumTypeScriptVersion = "3.7"; + + /// Dictionary mapping FHIR primitive types to language equivalents. + private static readonly Dictionary _primitiveTypeMap = new Dictionary() + { + { "base", "Object" }, + { "base64Binary", "string" }, + { "boolean", "boolean" }, + { "canonical", "string" }, + { "code", "string" }, + { "date", "string" }, + { "dateTime", "string" }, + { "decimal", "number" }, + { "id", "string" }, + { "instant", "string" }, + { "integer", "number" }, + { "integer64", "string" }, // int64 serializes as string, need to add custom handling here + { "markdown", "string" }, + { "oid", "string" }, + { "positiveInt", "number" }, + { "string", "string" }, + { "time", "string" }, + { "unsignedInt", "number" }, + { "uri", "string" }, + { "url", "string" }, + { "uuid", "string" }, + { "xhtml", "string" }, + }; + + /// Gets the reserved words. + /// The reserved words. + private static readonly HashSet _reservedWords = new HashSet() + { + "const", + "enum", + "export", + "interface", + "z", + }; + + /// The generics and type hints. + private static readonly Dictionary _genericsAndTypeHints = new Dictionary() + { + { + "Bundle", + new GenericTypeHintInfo() + { + Alias = "BundleContentType", + GenericHint = "FhirResource", + IncludeBase = true, + } + }, + { + "Bundle.entry", + new GenericTypeHintInfo() + { + Alias = "BundleContentType", + GenericHint = "FhirResource", + IncludeBase = true, + } + }, + { + "Bundle.entry.resource", + new GenericTypeHintInfo() + { + Alias = "BundleContentType", + GenericHint = string.Empty, + IncludeBase = false, + } + }, + }; + + /// Gets the name of the language. + /// The name of the language. + string ILanguage.LanguageName => _languageName; + + /// + /// Gets the single file extension for this language - null or empty indicates a multi-file + /// export (exporter should copy the contents of the directory). + /// + string ILanguage.SingleFileExportExtension => _singleFileExportExtension; + + /// Gets the FHIR primitive type map. + /// The FHIR primitive type map. + Dictionary ILanguage.FhirPrimitiveTypeMap => _primitiveTypeMap; + + /// Gets the reserved words. + /// The reserved words. + HashSet ILanguage.ReservedWords => _reservedWords; + + /// + /// Gets a list of FHIR class types that the language WILL export, regardless of user choices. + /// Used to provide information to users. + /// + List ILanguage.RequiredExportClassTypes => new List() + { + ExporterOptions.FhirExportClassType.ComplexType, + ExporterOptions.FhirExportClassType.Resource, + }; + + /// + /// Gets a list of FHIR class types that the language CAN export, depending on user choices. + /// + List ILanguage.OptionalExportClassTypes => new List() + { + }; + + /// Gets language-specific options and their descriptions. + Dictionary ILanguage.LanguageOptions => new Dictionary() + { + { "namespace", "Base namespace for TypeScript files (default: fhir{VersionNumber})." }, + { "min-ts-version", "Minimum TypeScript version (default: 3.7, use '-' for none)." } + }; + + /// Export the passed FHIR version into the specified directory. + /// The information. + /// Information describing the server. + /// Options for controlling the operation. + /// Directory to write files. + void ILanguage.Export( + FhirVersionInfo info, + FhirCapabiltyStatement serverInfo, + ExporterOptions options, + string exportDirectory) + { + // set internal vars so we don't pass them to every function + // this is ugly, but the interface patterns get bad quickly because we need the type map to copy the FHIR info + _info = info; + _options = options; + + _includeNamespace = _options.GetParam("namespace", false); + + _namespace = $"fhir{FhirPackageCommon.RForSequence(_info.FhirSequence).Substring(1).ToLowerInvariant()}.zod"; + + _minimumTypeScriptVersion = _options.GetParam("min-ts-version", "3.7"); + + _exportedCodes = new HashSet(); + _exportedResources = new List(); + + // create a filename for writing (single file for now) + string filename = Path.Combine(exportDirectory, $"zod{info.FhirSequence}.ts"); + + using (FileStream stream = new FileStream(filename, FileMode.Create)) + using (ExportStreamWriter writer = new ExportStreamWriter(stream)) + { + _writer = writer; + + WriteHeader(); + + WriteComplexes(_info.ComplexTypes.Values, false); + WriteComplexes(_info.Resources.Values, true); + WriteFHIRResourceSchema(); + WriteExports(); + WriteFooter(); + } + } + + /// Writes the complexes. + /// The complexes. + /// (Optional) True if is resource, false if not. + private void WriteComplexes( + IEnumerable complexes, + bool isResource = false) + { + foreach (FhirComplex complex in complexes.OrderBy(c => c.Name)) + { + WriteComplex(complex, isResource); + } + } + + /// Writes a complex. + /// The complex. + /// True if is resource, false if not. + private void WriteComplex( + FhirComplex complex, + bool isResource) + { + // check for nested components + if (complex.Components != null) + { + foreach (FhirComplex component in complex.Components.Values) + { + WriteComplex(component, false); + } + } + + // zod schema name + string schemaName; + if (string.IsNullOrEmpty(complex.BaseTypeName) || + complex.Name.Equals("Element", StringComparison.Ordinal)) + { + schemaName = complex.NameForExport(FhirTypeBase.NamingConvention.PascalCase); + _writer.WriteLineIndented($"const {schemaName}Schema = z.lazy>>((): z.ZodObject> => {{"); + _writer.IncreaseIndent(); + _writer.WriteLineIndented($"return z.object({{"); + } + else if (complex.Name.Equals(complex.BaseTypeName, StringComparison.Ordinal)) + { + schemaName = complex.NameForExport(FhirTypeBase.NamingConvention.PascalCase, true); + _writer.WriteLineIndented($"const {schemaName}Schema = z.lazy>>((): z.ZodObject> => {{"); + _writer.IncreaseIndent(); + _writer.WriteLineIndented($"return z.object({{"); + } + else + { + schemaName = complex.NameForExport(FhirTypeBase.NamingConvention.PascalCase, true); + string typeName = complex.TypeForExport(FhirTypeBase.NamingConvention.PascalCase, _primitiveTypeMap, false); + _writer.WriteLineIndented($"const {schemaName}Schema = z.lazy>>((): z.ZodObject> => {{"); + _writer.IncreaseIndent(); + _writer.WriteLineIndented($"return {typeName}Schema.schema.extend({{"); + } + + _writer.IncreaseIndent(); + + if (isResource) + { + if (ShouldWriteResourceType(complex.Name)) + { + _exportedResources.Add(schemaName); + _writer.WriteLineIndented($"resourceType: z.literal('{complex.Name}'),"); + } + else + { + _writer.WriteLineIndented($"resourceType: z.string(),"); + } + } + + // write elements + WriteElements(complex, out List elementsWithCodes); + + _writer.DecreaseIndent(); + + // close interface (type) + _writer.WriteLineIndented("});"); + + + _writer.DecreaseIndent(); + _writer.WriteLine("});"); + } + + /// Writes the expanded resource interface binding. + private void WriteExports() + { + _exportedResources.Sort(); + + _writer.WriteLine("export {"); + int index = 0; + int last = _exportedResources.Count - 1; + _writer.IncreaseIndent(); + foreach (string exportedName in _exportedResources) + { + _writer.WriteLineIndented($"{exportedName}Schema{(index != last ? "," : "")}"); + index++; + } + _writer.DecreaseIndent(); + _writer.WriteLine("};"); + } + + private void WriteFHIRResourceSchema() + { + _exportedResources.Sort(); + + _writer.WriteLine("FhirResourceSchema = z.union(["); + int index = 0; + int last = _exportedResources.Count - 1; + _writer.IncreaseIndent(); + foreach (string exportedName in _exportedResources) + { + _writer.WriteLineIndented($"{exportedName}Schema{(index != last ? "," : "")}"); + index++; + } + _writer.DecreaseIndent(); + _writer.WriteLine("]);"); + } + + /// Determine if we should write resource name. + /// The name. + /// True if it succeeds, false if it fails. + private static bool ShouldWriteResourceType(string name) + { + switch (name) + { + case "Resource": + case "DomainResource": + case "MetadataResource": + case "CanonicalResource": + return false; + } + + return true; + } + + /// Writes the elements. + /// The complex. + /// [out] The elements with codes. + private void WriteElements( + FhirComplex complex, + out List elementsWithCodes) + { + elementsWithCodes = new List(); + + foreach (FhirElement element in complex.Elements.Values.OrderBy(s => s.Name)) + { + if (element.IsInherited) + { + continue; + } + + WriteElement(complex, element); + + if ((element.Codes != null) && (element.Codes.Count > 0)) + { + elementsWithCodes.Add(element); + } + } + } + + /// Writes an element. + /// The complex. + /// The element. + private void WriteElement( + FhirComplex complex, + FhirElement element) + { + HashSet primitives = new HashSet(); + primitives.Add("string"); + primitives.Add("boolean"); + primitives.Add("number"); + Dictionary values = element.NamesAndTypesForExport( + FhirTypeBase.NamingConvention.CamelCase, + FhirTypeBase.NamingConvention.PascalCase, + false, + string.Empty, + complex.Components.ContainsKey(element.Path)); + + foreach (KeyValuePair kvp in values) + { + bool isPrimative = primitives.Contains(kvp.Value); + string result = $"{kvp.Key}: "; + + // Use generated enum for codes when required strength + // EXCLUDE the MIME type value set - those should be bound to strings + if (element.Codes != null + && element.Codes.Any() + && !string.IsNullOrEmpty(element.ValueSet) + && !string.IsNullOrEmpty(element.BindingStrength) + && string.Equals(element.BindingStrength, "required", StringComparison.Ordinal) + && (element.ValueSet != "http://www.rfc-editor.org/bcp/bcp13.txt") + && (!element.ValueSet.StartsWith("http://hl7.org/fhir/ValueSet/mimetypes", StringComparison.Ordinal))) + { + if (_info.TryGetValueSet(element.ValueSet, out FhirValueSet vs)) + { + if (element.IsArray) + { + result += $"z.array(z.enum([{string.Join(",", vs.Concepts.Select(c => $"'{c.Code}'"))}]))"; + } + else + { + result += $"z.enum([{string.Join(",", vs.Concepts.Select(c => $"'{c.Code}'"))}])"; + } + } + else + { + if (element.IsArray) + { + result += $"z.array(z.enum([{string.Join(",", element.Codes.Select(c => $"'{c}'"))}]))"; + } + else + { + result += $"z.enum([{string.Join(",", element.Codes.Select(c => $"'{c}'"))}])"; + } + } + } + else if (kvp.Value.Equals("Resource", StringComparison.Ordinal)) + { + if (element.IsArray) + { + result += $"z.array(FhirResourceSchema)"; + } + else + { + result += "FhirResourceSchema"; + } + } + else + { + if (element.IsArray) + { + result += $"z.array({(isPrimative ? "z." : "")}{kvp.Value}{(isPrimative ? "()" : "Schema")})"; + } + else + { + result += $"{(isPrimative ? "z." : "")}{kvp.Value}{(isPrimative ? "()" : "Schema")}"; + } + } + + // TODO various fields in the fhir spec are mutually exclusive ors (xors) + // but zod does not have an out of the box xor method, so using nullish + result+= ".nullish()"; + + if (element.IsOptional) + { + result += ".optional()"; + } + + /* worry about this later + if (!string.IsNullOrEmpty(element.Comment)) + { + result += $".description('{element.Comment}')"; + } + */ + + result += ","; + _writer.WriteLineIndented(result); + } + } + + /// Writes a header. + private void WriteHeader() + { + _writer.WriteLineIndented("// "); + _writer.WriteLineIndented($"// Contents of: {_info.PackageName} version: {_info.VersionString}"); + _writer.WriteLineIndented($" // Primitive Naming Style: {FhirTypeBase.NamingConvention.None}"); + _writer.WriteLineIndented($" // Complex Type / Resource Naming Style: {FhirTypeBase.NamingConvention.PascalCase}"); + _writer.WriteLineIndented($" // Interaction Naming Style: {FhirTypeBase.NamingConvention.None}"); + _writer.WriteLineIndented($" // Extension Support: {_options.ExtensionSupport}"); + + if ((_options.ExportList != null) && _options.ExportList.Any()) + { + string restrictions = string.Join("|", _options.ExportList); + _writer.WriteLineIndented($" // Restricted to: {restrictions}"); + } + + if ((_options.LanguageOptions != null) && (_options.LanguageOptions.Count > 0)) + { + foreach (KeyValuePair kvp in _options.LanguageOptions) + { + _writer.WriteLineIndented($" // Language option: \"{kvp.Key}\" = \"{kvp.Value}\""); + } + } + + if (!_minimumTypeScriptVersion.Equals("-")) + { + _writer.WriteLine($"// Minimum TypeScript Version: {_minimumTypeScriptVersion}"); + } + + // import zod as z + _writer.WriteLine("import { z } from 'zod';"); + _writer.WriteLine(); + // zod does not support inheritance so we need to use composition via union + // we also have a hoisting problem + _writer.WriteLine("let FhirResourceSchema: z.ZodTypeAny;"); + + if (_includeNamespace) + { + _writer.WriteLineIndented($"export as namespace {_namespace};"); + } + } + + /// Writes a footer. + private void WriteFooter() + { + return; + } + + /// Writes an indented comment. + /// The value. + private void WriteIndentedComment(string value) + { + _writer.WriteLineIndented($"/**"); + + string comment = value.Replace('\r', '\n').Replace("\r\n", "\n", StringComparison.Ordinal).Replace("\n\n", "\n", StringComparison.Ordinal); + + string[] lines = comment.Split('\n'); + foreach (string line in lines) + { + _writer.WriteIndented(" * "); + _writer.WriteLine(line); + } + + _writer.WriteLineIndented($" */"); + } + + /// Information about the generic type hint. + private struct GenericTypeHintInfo + { + internal string Alias; + internal bool IncludeBase; + internal string GenericHint; + } + } +}