From d10a2cc509a37f6c9921d9b38c2fa4e65185c3c8 Mon Sep 17 00:00:00 2001 From: Joris Kraak Date: Tue, 12 Jul 2022 10:49:47 +0200 Subject: [PATCH 1/3] refactor: directly define string literals using diagram type `Symbol`s The supported diagrams are already defined using `Symbol`s in the `LIMITED_DIAGRAM_SUPPORT`. The previous 'string indirection' existed solely due to the macro interpolation not having been completely understood. Ensuring the diagram `type` `Symbol`s are quoted twice removes the need for the string indirection. --- src/Kroki.jl | 66 +++++++++++++++++++++++++++------------------------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/src/Kroki.jl b/src/Kroki.jl index c627515..4fa1372 100644 --- a/src/Kroki.jl +++ b/src/Kroki.jl @@ -335,41 +335,43 @@ end # Links to the main documentation for each diagram type for inclusion in the # string literal docstrings -DIAGRAM_DOCUMENTATION_URLS = Dict{String, String}( - "actdiag" => "http://blockdiag.com/en/actdiag", - "blockdiag" => "http://blockdiag.com/en/blockdiag", - "bpmn" => "https://www.omg.org/spec/BPMN", - "bytefield" => "https://bytefield-svg.deepsymmetry.org", - "c4plantuml" => "https://github.com/plantuml-stdlib/C4-PlantUML", - "ditaa" => "http://ditaa.sourceforge.net", - "erd" => "https://github.com/BurntSushi/erd", - "excalidraw" => "https://excalidraw.com", - "graphviz" => "https://graphviz.org", - "mermaid" => "https://mermaid-js.github.io", - "nomnoml" => "https://www.nomnoml.com", - "nwdiag" => "http://blockdiag.com/en/nwdiag", - "packetdiag" => "http://blockdiag.com/en/nwdiag", - "pikchr" => "https://pikchr.org", - "plantuml" => "https://plantuml.com", - "rackdiag" => "http://blockdiag.com/en/nwdiag", - "seqdiag" => "http://blockdiag.com/en/seqdiag", - "structurizr" => "https://structurizr.com", - "svgbob" => "https://ivanceras.github.io/content/Svgbob.html", - "umlet" => "https://github.com/umlet/umlet", - "vega" => "https://vega.github.io/vega", - "vegalite" => "https://vega.github.io/vega-lite", - "wavedrom" => "https://wavedrom.com", +DIAGRAM_DOCUMENTATION_URLS = Dict{Symbol, String}( + :actdiag => "http://blockdiag.com/en/actdiag", + :blockdiag => "http://blockdiag.com/en/blockdiag", + :bpmn => "https://www.omg.org/spec/BPMN", + :bytefield => "https://bytefield-svg.deepsymmetry.org", + :c4plantuml => "https://github.com/plantuml-stdlib/C4-PlantUML", + :ditaa => "http://ditaa.sourceforge.net", + :erd => "https://github.com/BurntSushi/erd", + :excalidraw => "https://excalidraw.com", + :graphviz => "https://graphviz.org", + :mermaid => "https://mermaid-js.github.io", + :nomnoml => "https://www.nomnoml.com", + :nwdiag => "http://blockdiag.com/en/nwdiag", + :packetdiag => "http://blockdiag.com/en/nwdiag", + :pikchr => "https://pikchr.org", + :plantuml => "https://plantuml.com", + :rackdiag => "http://blockdiag.com/en/nwdiag", + :seqdiag => "http://blockdiag.com/en/seqdiag", + :structurizr => "https://structurizr.com", + :svgbob => "https://ivanceras.github.io/content/Svgbob.html", + :umlet => "https://github.com/umlet/umlet", + :vega => "https://vega.github.io/vega", + :vegalite => "https://vega.github.io/vega-lite", + :wavedrom => "https://wavedrom.com", ) -for diagram_type in map( - # The union of the values of `LIMITED_DIAGRAM_SUPPORT` corresponds to all - # supported `Diagram` types. Converting the `Symbol`s to `String`s improves - # readability of the `macro` bodies - String, - collect(Set(Iterators.flatten(values(LIMITED_DIAGRAM_SUPPORT)))), -) +# The union of the values of `LIMITED_DIAGRAM_SUPPORT` corresponds to all +# supported `Diagram` types. The `values` call returns an array of arrays that +# may contain duplicate diagram types due to some types supporting rendering to +# multiple MIME types +for diagram_type in unique(Iterators.flatten(values(LIMITED_DIAGRAM_SUPPORT))) macro_name = Symbol("$(diagram_type)_str") macro_signature = Symbol("@$macro_name") + # To be able to interpolate the `diagram_type` into the macro's body it needs + # to be quoted twice, so that it does not get interpreted as the name of a + # variable. First for `@eval`, then for the macro itself + macro_diagram_type = QuoteNode(QuoteNode(diagram_type)) diagram_url = get(DIAGRAM_DOCUMENTATION_URLS, diagram_type, "https://kroki.io/#support") @@ -382,7 +384,7 @@ for diagram_type in map( Expr( :call, :Diagram, - QuoteNode(Symbol($diagram_type)), + $macro_diagram_type, Expr(:call, string, interpolate(specification)...), ) end From d1e814dba66fa89cc6b01662099a7b2e1ed172ed Mon Sep 17 00:00:00 2001 From: Joris Kraak Date: Tue, 12 Jul 2022 13:23:20 +0200 Subject: [PATCH 2/3] refactor: move string literals into a dedicated module This functionality is relatively self-contained meta-progamming, moving it into its own module helps to clearly delineate the metaprogramming from the core functionality of the package. --- Project.toml | 2 + docs/Manifest.toml | 7 ++- docs/src/api.md | 8 ++- src/Kroki.jl | 94 +---------------------------- src/string_literals.jl | 97 ++++++++++++++++++++++++++++++ test/kroki/string_literals_test.jl | 32 ++++++++++ test/runtests.jl | 30 +-------- 7 files changed, 147 insertions(+), 123 deletions(-) create mode 100644 src/string_literals.jl create mode 100644 test/kroki/string_literals_test.jl diff --git a/Project.toml b/Project.toml index 307dc41..87e15a5 100644 --- a/Project.toml +++ b/Project.toml @@ -8,6 +8,7 @@ Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" CodecZlib = "944b1d66-785c-5afd-91f1-9de20f533193" DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" +Reexport = "189a3867-3050-52da-a836-e630ba90ab69" [compat] CodecZlib = "0.7" @@ -18,4 +19,5 @@ DocStringExtensions = "0.8, 0.9" # The wider compatibility range for `HTTP` ensures compatibility with # up-to-date versions of `Pluto`, etc. HTTP = "0.8, 0.9, 1" +Reexport = "1.2.2" julia = "1.6" diff --git a/docs/Manifest.toml b/docs/Manifest.toml index bb13b34..acbac7c 100644 --- a/docs/Manifest.toml +++ b/docs/Manifest.toml @@ -64,7 +64,7 @@ uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" version = "0.21.3" [[deps.Kroki]] -deps = ["Base64", "CodecZlib", "DocStringExtensions", "HTTP"] +deps = ["Base64", "CodecZlib", "DocStringExtensions", "HTTP", "Reexport"] path = ".." uuid = "b3565e16-c1f2-4fe9-b4ab-221c88942068" @@ -116,6 +116,11 @@ uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" deps = ["SHA", "Serialization"] uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +[[deps.Reexport]] +git-tree-sha1 = "45e428421666073eab6f2da5c9d310d99bb12f9b" +uuid = "189a3867-3050-52da-a836-e630ba90ab69" +version = "1.2.2" + [[deps.SHA]] uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" diff --git a/docs/src/api.md b/docs/src/api.md index e929be2..365a17e 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -23,10 +23,12 @@ Filter = name -> "$name" !== "executeDockerCompose" ``` ### [String Literals](@id api-string-literals) + +The following string literals are exported from the [`Kroki`](@ref) module to +make it more straightforward to instantiate `Diagram`s. + ```@autodocs -Modules = [ Kroki ] -Order = [ :macro ] -Filter = m -> endswith("$m", "_str") +Modules = [ Kroki.StringLiterals ] ``` ## Private diff --git a/src/Kroki.jl b/src/Kroki.jl index 4fa1372..b1eaea5 100644 --- a/src/Kroki.jl +++ b/src/Kroki.jl @@ -13,6 +13,7 @@ using Base64: base64encode using CodecZlib: ZlibCompressor, transcode using HTTP: request using HTTP.ExceptionRequest: StatusError +using Reexport: @reexport include("./kroki/documentation.jl") using .Documentation @@ -299,96 +300,7 @@ Base.show(io::IO, diagram::Diagram) = write(io, diagram.specification) end -# Helper function implementing string interpolation to be used in conjunction -# with macros defining diagram specification string literals, as they do not -# support string interpolation by default. -# -# Returns an array of elements, e.g. `Expr`essions, `Symbol`s, `String`s that -# can be incorporated in the `args` of another `Expr`essions -function interpolate(specification::AbstractString) - # Based on the interpolation code from the Markdown stdlib and - # https://riptutorial.com/julia-lang/example/22952/implementing-interpolation-in-a-string-macro - components = Any[] - - # Turn the string is into an `IOBuffer` to make it more straightforward to - # parse it in an incremental fashion - stream = IOBuffer(specification) - - while !eof(stream) - # The `$` is omitted from the result by `readuntil` by default, no need for - # further processing - push!(components, readuntil(stream, '$')) - - if !eof(stream) - # If an interpolation indicator was found, try to parse the smallest - # expression to interpolate and then keep parsing the stream for further - # interpolations - started_at = position(stream) - expr, parsed_count = Meta.parse(read(stream, String), 1; greedy = false) - seek(stream, started_at + parsed_count - 1) - push!(components, expr) - end - end - - esc.(components) -end - -# Links to the main documentation for each diagram type for inclusion in the -# string literal docstrings -DIAGRAM_DOCUMENTATION_URLS = Dict{Symbol, String}( - :actdiag => "http://blockdiag.com/en/actdiag", - :blockdiag => "http://blockdiag.com/en/blockdiag", - :bpmn => "https://www.omg.org/spec/BPMN", - :bytefield => "https://bytefield-svg.deepsymmetry.org", - :c4plantuml => "https://github.com/plantuml-stdlib/C4-PlantUML", - :ditaa => "http://ditaa.sourceforge.net", - :erd => "https://github.com/BurntSushi/erd", - :excalidraw => "https://excalidraw.com", - :graphviz => "https://graphviz.org", - :mermaid => "https://mermaid-js.github.io", - :nomnoml => "https://www.nomnoml.com", - :nwdiag => "http://blockdiag.com/en/nwdiag", - :packetdiag => "http://blockdiag.com/en/nwdiag", - :pikchr => "https://pikchr.org", - :plantuml => "https://plantuml.com", - :rackdiag => "http://blockdiag.com/en/nwdiag", - :seqdiag => "http://blockdiag.com/en/seqdiag", - :structurizr => "https://structurizr.com", - :svgbob => "https://ivanceras.github.io/content/Svgbob.html", - :umlet => "https://github.com/umlet/umlet", - :vega => "https://vega.github.io/vega", - :vegalite => "https://vega.github.io/vega-lite", - :wavedrom => "https://wavedrom.com", -) - -# The union of the values of `LIMITED_DIAGRAM_SUPPORT` corresponds to all -# supported `Diagram` types. The `values` call returns an array of arrays that -# may contain duplicate diagram types due to some types supporting rendering to -# multiple MIME types -for diagram_type in unique(Iterators.flatten(values(LIMITED_DIAGRAM_SUPPORT))) - macro_name = Symbol("$(diagram_type)_str") - macro_signature = Symbol("@$macro_name") - # To be able to interpolate the `diagram_type` into the macro's body it needs - # to be quoted twice, so that it does not get interpreted as the name of a - # variable. First for `@eval`, then for the macro itself - macro_diagram_type = QuoteNode(QuoteNode(diagram_type)) - - diagram_url = get(DIAGRAM_DOCUMENTATION_URLS, diagram_type, "https://kroki.io/#support") - - docstring = "String literal for instantiating [`$diagram_type`]($diagram_url) [`Diagram`](@ref)s." - - @eval begin - export $macro_signature - - @doc $docstring macro $macro_name(specification::AbstractString) - Expr( - :call, - :Diagram, - $macro_diagram_type, - Expr(:call, string, interpolate(specification)...), - ) - end - end -end +include("./string_literals.jl") +@reexport using .StringLiterals end diff --git a/src/string_literals.jl b/src/string_literals.jl new file mode 100644 index 0000000..961aea9 --- /dev/null +++ b/src/string_literals.jl @@ -0,0 +1,97 @@ +module StringLiterals + +using ..Kroki: Diagram, LIMITED_DIAGRAM_SUPPORT + +# Helper function implementing string interpolation to be used in conjunction +# with macros defining diagram specification string literals, as they do not +# support string interpolation by default. +# +# Returns an array of elements, e.g. `Expr`essions, `Symbol`s, `String`s that +# can be incorporated in the `args` of another `Expr`essions +function interpolate(specification::AbstractString) + # Based on the interpolation code from the Markdown stdlib and + # https://riptutorial.com/julia-lang/example/22952/implementing-interpolation-in-a-string-macro + components = Any[] + + # Turn the string is into an `IOBuffer` to make it more straightforward to + # parse it in an incremental fashion + stream = IOBuffer(specification) + + while !eof(stream) + # The `$` is omitted from the result by `readuntil` by default, no need for + # further processing + push!(components, readuntil(stream, '$')) + + if !eof(stream) + # If an interpolation indicator was found, try to parse the smallest + # expression to interpolate and then keep parsing the stream for further + # interpolations + started_at = position(stream) + expr, parsed_count = Meta.parse(read(stream, String), 1; greedy = false) + seek(stream, started_at + parsed_count - 1) + push!(components, expr) + end + end + + esc.(components) +end + +# Links to the main documentation for each diagram type for inclusion in the +# string literal docstrings +DIAGRAM_DOCUMENTATION_URLS = Dict{Symbol, String}( + :actdiag => "http://blockdiag.com/en/actdiag", + :blockdiag => "http://blockdiag.com/en/blockdiag", + :bpmn => "https://www.omg.org/spec/BPMN", + :bytefield => "https://bytefield-svg.deepsymmetry.org", + :c4plantuml => "https://github.com/plantuml-stdlib/C4-PlantUML", + :ditaa => "http://ditaa.sourceforge.net", + :erd => "https://github.com/BurntSushi/erd", + :excalidraw => "https://excalidraw.com", + :graphviz => "https://graphviz.org", + :mermaid => "https://mermaid-js.github.io", + :nomnoml => "https://www.nomnoml.com", + :nwdiag => "http://blockdiag.com/en/nwdiag", + :packetdiag => "http://blockdiag.com/en/nwdiag", + :pikchr => "https://pikchr.org", + :plantuml => "https://plantuml.com", + :rackdiag => "http://blockdiag.com/en/nwdiag", + :seqdiag => "http://blockdiag.com/en/seqdiag", + :structurizr => "https://structurizr.com", + :svgbob => "https://ivanceras.github.io/content/Svgbob.html", + :umlet => "https://github.com/umlet/umlet", + :vega => "https://vega.github.io/vega", + :vegalite => "https://vega.github.io/vega-lite", + :wavedrom => "https://wavedrom.com", +) + +# The union of the values of `LIMITED_DIAGRAM_SUPPORT` corresponds to all +# supported `Diagram` types. The `values` call returns an array of arrays that +# may contain duplicate diagram types due to some types supporting rendering to +# multiple MIME types +for diagram_type in unique(Iterators.flatten(values(LIMITED_DIAGRAM_SUPPORT))) + macro_name = Symbol("$(diagram_type)_str") + macro_signature = Symbol("@$macro_name") + # To be able to interpolate the `diagram_type` into the macro's body it needs + # to be quoted twice, so that it does not get interpreted as the name of a + # variable. First for `@eval`, then for the macro itself + macro_diagram_type = QuoteNode(QuoteNode(diagram_type)) + + diagram_url = get(DIAGRAM_DOCUMENTATION_URLS, diagram_type, "https://kroki.io/#support") + + docstring = "String literal for instantiating [`$diagram_type`]($diagram_url) [`Diagram`](@ref)s." + + @eval begin + export $macro_signature + + @doc $docstring macro $macro_name(specification::AbstractString) + Expr( + :call, + :Diagram, + $macro_diagram_type, + Expr(:call, string, interpolate(specification)...), + ) + end + end +end + +end diff --git a/test/kroki/string_literals_test.jl b/test/kroki/string_literals_test.jl new file mode 100644 index 0000000..1c466ae --- /dev/null +++ b/test/kroki/string_literals_test.jl @@ -0,0 +1,32 @@ +module StringLiteralsTest + +using Test: @test, @testset + +using Kroki: Diagram, @mermaid_str, @plantuml_str + +@testset "diagram string literals" begin + # String literal macros should be defined as a convenient way for + # specifying diagrams. + # + # This is not an exhaustive test as these are dynamically generated. The + # basic functionality is only verified for key diagram types + string_literal_plantuml = plantuml"A -> B: C" + diagram_type_plantuml = Diagram(:PlantUML, "A -> B: C") + # The leading newlines make sure the alignment of the plain text + # representations is identical across both calling methods + @test "\n$string_literal_plantuml" == "\n$diagram_type_plantuml" + + string_literal_mermaid = mermaid"graph TD; A --> B" + diagram_type_mermaid = Diagram(:mermaid, "graph TD; A --> B") + @test string_literal_mermaid == diagram_type_mermaid + + @testset "support interpolation" begin + # String macros do no support string interpolation out-of-the-box, this + # needs to be manually implemented, so needs dedicated testing + message = "Z" + diagram = plantuml"X -> Y: $(message ^ 5)" + @test occursin(message^5, diagram.specification) + end +end + +end diff --git a/test/runtests.jl b/test/runtests.jl index cc52f05..f4dd0cb 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -3,8 +3,6 @@ module KrokiTest using Test: @testset, @test, @test_nowarn, @test_throws using Kroki: - @mermaid_str, - @plantuml_str, Diagram, DiagramPathOrSpecificationError, InvalidDiagramSpecificationError, @@ -191,31 +189,6 @@ end end end - @testset "diagram string literals" begin - # String literal macros should be defined as a convenient way for - # specifying diagrams. - # - # This is not an exhaustive test as these are dynamically generated. The - # basic functionality is only verified for key diagram types - string_literal_plantuml = plantuml"A -> B: C" - diagram_type_plantuml = Diagram(:PlantUML, "A -> B: C") - # The leading newlines make sure the alignment of the plain text - # representations is identical across both calling methods - @test "\n$string_literal_plantuml" == "\n$diagram_type_plantuml" - - string_literal_mermaid = mermaid"graph TD; A --> B" - diagram_type_mermaid = Diagram(:mermaid, "graph TD; A --> B") - @test string_literal_mermaid == diagram_type_mermaid - - @testset "supports interpolation" begin - # String macros do no support string interpolation out-of-the-box, this - # needs to be manually implemented, so needs dedicated testing - message = "Z" - diagram = plantuml"X -> Y: $(message ^ 5)" - @test occursin(message^5, diagram.specification) - end - end - @testset "`Base.show`" begin # Svgbob diagrams only support SVG output. Any other formats should throw # `InvalidOutputFormatError`s when called directly. @@ -243,7 +216,8 @@ end end end - include("./kroki/service_test.jl") + # Include the test suites for all submodules + include.(readdir(joinpath(@__DIR__, "kroki"); join = true)) end end From c43c9e2fe3e0a5eb0d1614fb82f185515fcae2dd Mon Sep 17 00:00:00 2001 From: Joris Kraak Date: Tue, 12 Jul 2022 14:36:48 +0200 Subject: [PATCH 3/3] refactor: move exceptions into a dedicated module Exceptions require some boilerplate to render nicely and obstruct the flow of the definition of the main functionality of the package. Extracting them into a separate module decreases the amount of code necessary to grasp the core concepts of the package in both the package itself as well as its tests. --- docs/architecture/workspace.dsl | 4 ++ docs/src/api.md | 9 ++- src/Kroki.jl | 106 +++------------------------ src/kroki/exceptions.jl | 99 ++++++++++++++++++++++++++ test/kroki/exceptions_test.jl | 120 +++++++++++++++++++++++++++++++ test/runtests.jl | 122 ++------------------------------ 6 files changed, 243 insertions(+), 217 deletions(-) create mode 100644 src/kroki/exceptions.jl create mode 100644 test/kroki/exceptions_test.jl diff --git a/docs/architecture/workspace.dsl b/docs/architecture/workspace.dsl index cc085a0..3c807bb 100644 --- a/docs/architecture/workspace.dsl +++ b/docs/architecture/workspace.dsl @@ -14,6 +14,10 @@ workspace { description "Facilitates definition and rendering of different diagram types." } + component Exceptions { + description "Provides all exceptions functionality in the package can throw." + } + service_management = component "Service Management" { description "Provides management over the 'Kroki Service' that is being used, including managing a local instance." } diff --git a/docs/src/api.md b/docs/src/api.md index 365a17e..7d08d8c 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -34,9 +34,6 @@ Modules = [ Kroki.StringLiterals ] ## Private ```@docs -DiagramPathOrSpecificationError -InvalidDiagramSpecificationError -InvalidOutputFormatError LIMITED_DIAGRAM_SUPPORT UriSafeBase64Payload ``` @@ -47,6 +44,12 @@ UriSafeBase64Payload Modules = [ Kroki.Documentation ] ``` +### Exceptions + +```@autodocs +Modules = [ Kroki.Exceptions ] +``` + ### Service Management ```@autodocs diff --git a/src/Kroki.jl b/src/Kroki.jl index b1eaea5..4070bad 100644 --- a/src/Kroki.jl +++ b/src/Kroki.jl @@ -12,7 +12,6 @@ module Kroki using Base64: base64encode using CodecZlib: ZlibCompressor, transcode using HTTP: request -using HTTP.ExceptionRequest: StatusError using Reexport: @reexport include("./kroki/documentation.jl") @@ -62,6 +61,9 @@ of diagram. """ Diagram(type::Symbol, specification::AbstractString) = Diagram(specification, type) +include("./kroki/exceptions.jl") +using .Exceptions: DiagramPathOrSpecificationError, RenderError + """ Constructs a [`Diagram`](@ref) from the `specification` for a specific `type` of diagram, or loads the `specification` from the provided `path`. @@ -87,80 +89,6 @@ function Diagram( end end -""" -An `Exception` to be thrown when the `path` and `specification` keyword -arguments to [`Diagram`](@ref) are not specified mutually exclusive. -""" -struct DiagramPathOrSpecificationError <: Exception - path::Maybe{AbstractString} - specification::Maybe{AbstractString} -end - -function Base.showerror(io::IO, error::DiagramPathOrSpecificationError) - not_specified = "" - - path_description = isnothing(error.path) ? not_specified : error.path - specification_description = - isnothing(error.specification) ? not_specified : error.specification - - message = """ - Either `path` or `specification` should be specified: - * `path`: '$(path_description)' - * `specification`: '$(specification_description)' - """ - - print(io, message) -end - -""" -An `Exception` to be thrown when a [`Diagram`](@ref) representing an invalid -specification is passed to [`render`](@ref). -""" -struct InvalidDiagramSpecificationError <: Exception - error::String - cause::Diagram -end - -Base.showerror(io::IO, error::InvalidDiagramSpecificationError) = print( - io, - """ - $(RenderErrorHeader(error)) - - This is (likely) caused by an invalid diagram specification. - """, -) - -""" -An `Exception` to be thrown when a [`Diagram`](@ref) is [`render`](@ref)ed to -an unsupported or invalid output format. -""" -struct InvalidOutputFormatError <: Exception - error::String - cause::Diagram -end - -Base.showerror(io::IO, error::InvalidOutputFormatError) = print( - io, - """ - $(RenderErrorHeader(error)) - - This is (likely) caused by an invalid or unknown output format. - """, -) - -# Helper function to render common headers when showing render errors -function RenderErrorHeader( - error::Union{InvalidDiagramSpecificationError, InvalidOutputFormatError}, -) - """ - The Kroki service responded with: - $(error.error) - - In response to a '$(error.cause.type)' diagram with the specification: - $(error.cause.specification) - """ -end - """ Compresses a [`Diagram`](@ref)'s `specification` using [zlib](https://zlib.net), turning the resulting bytes into a URL-safe Base64 @@ -176,33 +104,17 @@ UriSafeBase64Payload(diagram::Diagram) = foldl( init = base64encode(transcode(ZlibCompressor, diagram.specification)), ) -# Rewrites generic `HTTP.ExceptionRequest.StatusError`s into more specific -# errors based on Kroki's response if possible -function RenderError(diagram::Diagram, exception::StatusError) - # Both errors related to invalid diagram specifications and invalid or - # unsupported output formats are denoted by 400 responses, so further - # processing of the response is necessary - service_response = String(exception.response.body) - - if occursin("Unsupported output format", service_response) - InvalidOutputFormatError(service_response, diagram) - elseif occursin("Syntax Error", service_response) - InvalidDiagramSpecificationError(service_response, diagram) - else - exception - end -end -RenderError(::Diagram, exception::Exception) = exception - """ Renders a [`Diagram`](@ref) through a Kroki service to the specified output format. If the Kroki service responds with an error, throws an -[`InvalidDiagramSpecificationError`](@ref) or -[`InvalidOutputFormatError`](@ref) if a know type of error occurs. Other errors -(e.g. `HTTP.ExceptionRequest.StatusError` for connection errors) are propagated -if they occur. +[`InvalidDiagramSpecificationError`](@ref +Kroki.Exceptions.InvalidDiagramSpecificationError) or +[`InvalidOutputFormatError`](@ref Kroki.Exceptions.InvalidOutputFormatError) if +a know type of error occurs. Other errors (e.g. +`HTTP.ExceptionRequest.StatusError` for connection errors) are propagated if +they occur. _SVG output is supported for all [`Diagram`](@ref) types_. See [Kroki's website](https://kroki.io/#support) for an overview of other supported output diff --git a/src/kroki/exceptions.jl b/src/kroki/exceptions.jl new file mode 100644 index 0000000..98ec9ac --- /dev/null +++ b/src/kroki/exceptions.jl @@ -0,0 +1,99 @@ +module Exceptions + +using HTTP.ExceptionRequest: StatusError + +using ..Kroki: Diagram, Kroki, Maybe + +""" +An `Exception` to be thrown when the `path` and `specification` keyword +arguments to [`Diagram`](@ref) are not specified mutually exclusive. +""" +struct DiagramPathOrSpecificationError <: Exception + path::Maybe{AbstractString} + specification::Maybe{AbstractString} +end + +function Base.showerror(io::IO, error::DiagramPathOrSpecificationError) + not_specified = "" + + path_description = isnothing(error.path) ? not_specified : error.path + specification_description = + isnothing(error.specification) ? not_specified : error.specification + + message = """ + Either `path` or `specification` should be specified: + * `path`: '$(path_description)' + * `specification`: '$(specification_description)' + """ + + print(io, message) +end + +""" +An `Exception` to be thrown when a [`Diagram`](@ref) representing an invalid +specification is passed to [`render`](@ref Kroki.render). +""" +struct InvalidDiagramSpecificationError <: Exception + error::String + cause::Diagram +end + +Base.showerror(io::IO, error::InvalidDiagramSpecificationError) = print( + io, + """ + $(RenderErrorHeader(error)) + + This is (likely) caused by an invalid diagram specification. + """, +) + +""" +An `Exception` to be thrown when a [`Diagram`](@ref) is [`render`](@ref +Kroki.render)ed to an unsupported or invalid output format. +""" +struct InvalidOutputFormatError <: Exception + error::String + cause::Diagram +end + +Base.showerror(io::IO, error::InvalidOutputFormatError) = print( + io, + """ + $(RenderErrorHeader(error)) + + This is (likely) caused by an invalid or unknown output format. + """, +) + +# Helper function to render common headers when showing render errors +function RenderErrorHeader( + error::Union{InvalidDiagramSpecificationError, InvalidOutputFormatError}, +) + """ + The Kroki service responded with: + $(error.error) + + In response to a '$(error.cause.type)' diagram with the specification: + $(error.cause.specification) + """ +end + +# Rewrites generic `HTTP.ExceptionRequest.StatusError`s into more specific +# errors based on Kroki's response if possible +function RenderError(diagram::Diagram, exception::StatusError) + # Both errors related to invalid diagram specifications and invalid or + # unsupported output formats are denoted by 400 responses, so further + # processing of the response is necessary + service_response = String(exception.response.body) + + if occursin("Unsupported output format", service_response) + InvalidOutputFormatError(service_response, diagram) + elseif occursin("Syntax Error", service_response) + InvalidDiagramSpecificationError(service_response, diagram) + else + exception + end +end +RenderError(::Diagram, exception::Exception) = exception + +end diff --git a/test/kroki/exceptions_test.jl b/test/kroki/exceptions_test.jl new file mode 100644 index 0000000..4b87052 --- /dev/null +++ b/test/kroki/exceptions_test.jl @@ -0,0 +1,120 @@ +module ExceptionsTest + +using Test: @test, @test_throws, @testset + +using Kroki: Diagram, render +using Kroki.Exceptions: + DiagramPathOrSpecificationError, + InvalidDiagramSpecificationError, + InvalidOutputFormatError, + StatusError # Imported from HTTP through Kroki +using Kroki.Service: setEndpoint! + +testRenderError( + title::AbstractString, + diagram_type::Symbol, + content::AbstractString, + output_format::AbstractString, + expected_error_type::DataType, +) = @testset "$(title)" begin + diagram = Diagram(diagram_type, content) + + @test_throws(expected_error_type, render(diagram, output_format)) + + @testset "rendering" begin + service_response = "$(title) response" + + rendered_error = sprint(showerror, expected_error_type(service_response, diagram)) + + @test occursin("Kroki service responded with:", rendered_error) + @test occursin(content, rendered_error) + @test occursin(service_response, rendered_error) + end +end + +@testset "exceptions" begin + @testset "invalid `Diagram` instantiation" begin + @testset "with invalid `path`/`specification` combinations errors" begin + @testset "specifying both" begin + @test_throws( + DiagramPathOrSpecificationError, + Diagram(:mermaid; path = tempname(), specification = "A -> B: C") + ) + end + + @testset "specifying neither" begin + @test_throws(DiagramPathOrSpecificationError, Diagram(:svgbob)) + end + + @testset "rendering" begin + expected_specification = "X -> Y: Z" + + rendered_error = + sprint(showerror, DiagramPathOrSpecificationError(nothing, "X -> Y: Z")) + + @test startswith( + rendered_error, + "Either `path` or `specification` should be specified:", + ) + @test occursin("* `path`: ''", rendered_error) + @test occursin("* `specification`: '$(expected_specification)'", rendered_error) + end + end + end + + @testset "`RenderError`" begin + # `RenderError`s are thrown from the `render` function whenever an error + # occurs. Some of these benefit from rewrites into more descriptive + # errors + @testset "transforms rewritable `HTTP.ExceptionRequest.StatusError`s" begin + # Errors in the Kroki service are thrown as + # `HTTP.ExceptionRequest.StatusError` and should be rewritten in more + # descriptive errors + testRenderError( + "invalid diagram specification", + :PlantUML, + # A missing `>` and message cause the specification to be invalid + "Julia - Kroki:", + "svg", + InvalidDiagramSpecificationError, + ) + + testRenderError( + "invalid output format", + :Mermaid, + # The Mermaid renderer does not support PNG output + "graph TD; A --> B;", + "jpeg", + InvalidOutputFormatError, + ) + end + + @testset "passes unknown `HTTP.ExceptionRequest.StatusError`s as-is" begin + # Any HTTP related errors that are not due to rendering errors in the + # Kroki service (e.g. unknown endpoints), should be thrown from + # `RenderError` as-is + @test_throws(StatusError, render(Diagram(:non_existent_diagram_type, ""), "svg")) + end + + @testset "passes other errors as-is" begin + # Non-`StatusError`s (e.g. `IOError`s due to incorrect hostnames should + # be thrown/returned as-is + expected_service_host = "http://localhost:1" + expected_diagram_type = :plantuml + setEndpoint!(expected_service_host) + + try + render(Diagram(expected_diagram_type, "A -> B: C"), "svg") + catch exception + rendered_buffer = sprint(showerror, exception) + + @test occursin("ECONNREFUSED", rendered_buffer) + @test occursin("$(expected_service_host)/$(expected_diagram_type)", rendered_buffer) + end + + setEndpoint!() + end + end +end + +end diff --git a/test/runtests.jl b/test/runtests.jl index f4dd0cb..6bf3424 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -2,36 +2,8 @@ module KrokiTest using Test: @testset, @test, @test_nowarn, @test_throws -using Kroki: - Diagram, - DiagramPathOrSpecificationError, - InvalidDiagramSpecificationError, - InvalidOutputFormatError, - StatusError, # Imported from HTTP through Kroki - render -using Kroki.Service: setEndpoint! - -testRenderError( - title::AbstractString, - diagram_type::Symbol, - content::AbstractString, - output_format::AbstractString, - expected_error_type::DataType, -) = @testset "$(title)" begin - diagram = Diagram(diagram_type, content) - - @test_throws(expected_error_type, render(diagram, output_format)) - - @testset "rendering" begin - service_response = "$(title) response" - - rendered_error = sprint(showerror, expected_error_type(service_response, diagram)) - - @test occursin("Kroki service responded with:", rendered_error) - @test occursin(content, rendered_error) - @test occursin(service_response, rendered_error) - end -end +using Kroki: Diagram, render +using Kroki.Exceptions: InvalidOutputFormatError function testShowMethodRenders( diagram::Diagram, @@ -42,8 +14,8 @@ function testShowMethodRenders( end @testset "Kroki" begin - @testset "`Diagram` instantiation providing" begin - @testset "`path` loads the file as the `specification" begin + @testset "`Diagram` instantiation" begin + @testset "providing `path` loads the file as the `specification" begin diagram_path = joinpath(@__DIR__, "assets", "plantuml-example.puml") expected_specification = read(diagram_path, String) @@ -52,40 +24,13 @@ end @test diagram.specification === expected_specification end - @testset "`specification` stores it" begin + @testset "providing `specification` stores it" begin expected_specification = "A -> B: C" diagram = Diagram(:plantuml; specification = expected_specification) @test diagram.specification === expected_specification end - - @testset "invalid `path`/`specification` combinations errors" begin - @testset "specifying both" begin - @test_throws( - DiagramPathOrSpecificationError, - Diagram(:mermaid; path = tempname(), specification = "A -> B: C") - ) - end - - @testset "specifying neither" begin - @test_throws(DiagramPathOrSpecificationError, Diagram(:svgbob)) - end - - @testset "rendering" begin - expected_specification = "X -> Y: Z" - - rendered_error = - sprint(showerror, DiagramPathOrSpecificationError(nothing, "X -> Y: Z")) - - @test startswith( - rendered_error, - "Either `path` or `specification` should be specified:", - ) - @test occursin("* `path`: ''", rendered_error) - @test occursin("* `specification`: '$(expected_specification)'", rendered_error) - end - end end @testset "`render`" begin @@ -130,63 +75,6 @@ end # after the render, these should be ignored when matching @test endswith(rendered, r"\s?") end - - @testset "`RenderError`" begin - # `RenderError`s are thrown from the `render` function whenever an error - # occurs. Some of these benefit from rewrites into more descriptive - # errors - @testset "transforms rewritable `HTTP.ExceptionRequest.StatusError`s" begin - # Errors in the Kroki service are thrown as - # `HTTP.ExceptionRequest.StatusError` and should be rewritten in more - # descriptive errors - testRenderError( - "invalid diagram specification", - :PlantUML, - # A missing `>` and message cause the specification to be invalid - "Julia - Kroki:", - "svg", - InvalidDiagramSpecificationError, - ) - - testRenderError( - "invalid output format", - :Mermaid, - # The Mermaid renderer does not support PNG output - "graph TD; A --> B;", - "jpeg", - InvalidOutputFormatError, - ) - end - - @testset "passes unknown `HTTP.ExceptionRequest.StatusError`s as-is" begin - # Any HTTP related errors that are not due to rendering errors in the - # Kroki service (e.g. unknown endpoints), should be thrown from - # `RenderError` as-is - @test_throws(StatusError, render(Diagram(:non_existent_diagram_type, ""), "svg")) - end - - @testset "passes other errors as-is" begin - # Non-`StatusError`s (e.g. `IOError`s due to incorrect hostnames should - # be thrown/returned as-is - expected_service_host = "http://localhost:1" - expected_diagram_type = :plantuml - setEndpoint!(expected_service_host) - - try - render(Diagram(expected_diagram_type, "A -> B: C"), "svg") - catch exception - rendered_buffer = sprint(showerror, exception) - - @test occursin("ECONNREFUSED", rendered_buffer) - @test occursin( - "$(expected_service_host)/$(expected_diagram_type)", - rendered_buffer, - ) - end - - setEndpoint!() - end - end end @testset "`Base.show`" begin