Created
July 16, 2021 19:09
-
-
Save manveru/5050af58be7e36bbc9d116eb5d8eccba to your computer and use it in GitHub Desktop.
Parsing configs in Crystal
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require "spec" | |
require "json" | |
require "uri" | |
require "option_parser" | |
module Test | |
annotation Flag | |
end | |
module Configuration | |
def initialize(hash : Hash(String, String), file : String?) | |
json = | |
if file | |
JSON.parse(File.read(file)) | |
else | |
JSON::Any.new({} of String => JSON::Any) | |
end | |
{% for ivar in @type.instance_vars %} | |
{% ann = ivar.annotation(Flag) %} | |
%hash_key = {{ivar.id.stringify}} | |
%env_key = {{ann[:env]}} | |
%value = hash[%hash_key]? || json[%hash_key]?.try(&.as_s) | |
%value = ENV[%env_key]? if %value.nil? && %env_key | |
{% if ivar.has_default_value? %} | |
%value ||= {{ ivar.default_value }} | |
{% end %} | |
{% unless ivar.type.nilable? %} | |
if %value.nil? | |
raise( | |
begin | |
notice = ["Missing value for the option '{{ivar.id}}'. Please set it one of these ways:"] | |
notice << "flag: '-{{ann[:short].id}}'" if {{ann[:short]}} | |
notice << "flag: '--{{ann[:long].id}}'" if {{ann[:long]}} | |
notice << "environment variable: '{{ann[:env].id}}'" if {{ann[:env]}} | |
notice.join("\n") | |
end | |
) | |
end | |
{% end %} | |
@{{ivar.id}} = convert(%value, {{ivar.type}}) | |
{% debug %} | |
{% end %} | |
end | |
def convert(value : String, kind : Array(String).class) | |
value.split(',') | |
end | |
def convert(value : String | Nil, kind : (String | Nil).class) | |
value if value | |
end | |
def convert(value : String, kind : URI.class) | |
URI.parse(value) | |
end | |
def convert(value : URI, kind : URI.class) | |
value | |
end | |
def convert(value : String, kind : String.class) | |
value | |
end | |
def convert(value : String | Nil, kind : (Path | Nil).class) | |
Path.new(value) if value | |
end | |
macro included | |
extend OptionParserFlags | |
def self.configure | |
hash = {} of String => String | |
yield(hash) | |
new(hash, nil) | |
end | |
end | |
end | |
module OptionParserFlags | |
def option_parser(parser, config) | |
{% for ivar in @type.instance_vars %} | |
{% ann = ivar.annotation(Flag) %} | |
%short = {{ann[:short]}} | |
%long = {{ann[:long]}} | |
if %short && %long | |
parser.on "-#{%short}=VALUE", "--#{%long}=VALUE", {{ann[:help]}} do |value| | |
config[{{ivar.id.stringify}}] = value | |
end | |
elsif %short | |
parser.on "-#{%short}=VALUE", {{ann[:help]}} do |value| | |
config[{{ivar.id.stringify}}] = value | |
end | |
elsif %long | |
parser.on "--#{%long}=VALUE", {{ann[:help]}} do |value| | |
config[{{ivar.id.stringify}}] = value | |
end | |
end | |
{% end %} | |
end | |
end | |
struct Config | |
include Configuration | |
@[Flag(short: 'a', long: "aa", env: "A", help: "a")] | |
property a : String | |
@[Flag(short: 'd', env: "D", help: "d")] | |
property d : String = "from default" | |
end | |
struct Sub | |
include Configuration | |
@[Flag(short: 's', env: "S", help: "s")] | |
property s : String | |
end | |
struct Types | |
include Configuration | |
@[Flag(help: "s")] | |
property url : URI | |
end | |
end | |
Spec.before_each do | |
ENV.delete "A" | |
end | |
describe Test::Configuration do | |
empty = {} of String => String | |
file = File.join(__DIR__, "fixtures/tiny.json.fixture") | |
it "is configurable from environment" do | |
ENV["A"] = "from env" | |
c = Test::Config.new(empty, file: nil) | |
c.a.should eq("from env") | |
end | |
it "is configurable from a hash" do | |
c = Test::Config.new({"a" => "from flag"}, file: nil) | |
c.a.should eq("from flag") | |
end | |
it "is configurable from a file" do | |
c = Test::Config.new(empty, file: file) | |
c.a.should eq("from file") | |
end | |
it "is configurable from default" do | |
c = Test::Config.new(empty, file: file) | |
c.d.should eq("from default") | |
end | |
it "prefers file over env" do | |
ENV["A"] = "from env" | |
c = Test::Config.new(empty, file: file) | |
c.a.should eq("from file") | |
end | |
it "prefers flag over file" do | |
ENV["A"] = "from env" | |
c = Test::Config.new({"a" => "from flag"}, file: file) | |
c.a.should eq("from flag") | |
end | |
it "is configurable on the fly" do | |
main_config = {} of String => String | |
sub_config = {} of String => String | |
op = OptionParser.new do |parser| | |
Test::Config.option_parser(parser, main_config) | |
parser.on "sub", "first subcommand" do | |
Test::Sub.option_parser(parser, sub_config) | |
end | |
end | |
op.parse(["sub", "-s", "from flag s", "--aa", "from flag a", "-d", "from flag d"]) | |
Test::Sub.new(sub_config, nil).s.should eq("from flag s") | |
Test::Config.new(main_config, nil).a.should eq("from flag a") | |
Test::Config.new(main_config, nil).d.should eq("from flag d") | |
end | |
it "handles different types" do | |
Test::Types.new({ | |
"url" => "http://example.com", | |
}, nil).url.should eq(URI.parse("http://example.com")) | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment