Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Inject #590

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open

Add support for Inject #590

wants to merge 15 commits into from

Conversation

decebals
Copy link
Member

@decebals
Copy link
Member Author

decebals commented Nov 20, 2021

Now everything (almost) is configurable via DI container and the things remain perfectly the same until now if no DI container is detected. Only one new tiny dependency is added (javax.inject) with scope provided .
I tested on pippo-demo and pippo-test projects to see that things work perfectly without inject.
I tested with Spring and Guice, in complex scenario (inject new webserver, new template engine, ..) and things work as expected.

From the beginning, we wanted this feature to be as unobtrusive as possible, with as less as possible changes.
My implementation started from the idea that both Spring and Guice (the most important DI containers from Java) have support for optional inject for the fields annotated with standard javax.inject.Inject.
The idea is to use java.util.Optional<T> option for that fields.
In theory the using of java.util.Optional is discouraged for fields (according documentation) but many people (included Spring and Guice teams that implemented a such support in their libraries) consider a perfect fit and I agree with that.
My idea was to add lazy initialization in getters (Application and ControllerApplication) and to initialize the injected fields that are not touched by injection.

I think that we can do the code more compact but this is another story. For example for each inject aware field we have a declaration and a lazy initialization getter (and in some cases a setter):

@Inject
private Optional<ErrorHandler> errorHandler = Optional.empty();

public ErrorHandler getErrorHandler() {
    if (!errorHandler.isPresent()) {
        errorHandler = Optional.of(new DefaultErrorHandler(this));
    }

    return errorHandler.get();
}

public void setErrorHandler(ErrorHandler errorHandler) {
    this.errorHandler = Optional.of(errorHandler);
}

I prefer something more verbose/light as:

@Inject
private Optional<ErrorHandler> errorHandler;

public ErrorHandler getErrorHandler() {
    return OptionalUtils.setOnNull(Supplier<ErrorHandler>).get();
}

Also the proposed implementation with lazy initialization getters come with some improvements from performance point of view because the objects are initialized only on request.

That is all. I will add in my next two comments how I tested with Spring and Guice. If this PR will be accepted, after merge I will update pippo-demo project (pippo-demo-spring and pippo-demo-guice). With this PR the pippo-spring and pippo-guice are no longer needed and should be deleted.

@decebals
Copy link
Member Author

decebals commented Nov 20, 2021

For Spring Test, I modified pippo-demo-spring

public class SpringApplication3 extends ControllerApplication {

    @Inject
    private List<? extends Controller> controllers;

    @Override
    protected void onInit() {
        // add routes for static content
        addPublicResourceRoute();
        addWebjarsResourceRoute();

        addControllers(controllers.toArray(new Controller[0]));
    }

}
@Configuration
@ComponentScan
public class SpringConfiguration3 extends SpringConfiguration {

    @Bean
    public ContactService contactService() {
        return new InMemoryContactService();
    }

    @Bean
    public TemplateEngine templateEngine() {
        return new SimpleTemplateEngine();
    }

    @Bean
    public Router router() {
        return new CustomRouter();
    }

    @Bean
    public WebServer webServer() {
        return new TjwsServer();
    }

    @Bean
    public PippoSettings pippoSettings() {
        return new PippoSettings();
    }

    @Bean
    public Application application() {
        return new SpringApplication3();
    }

    @Bean
    public Pippo pippo() {
        return new Pippo(application()).setServer(webServer());
    }

}
public class SpringDemo3 {

    public static void main(String[] args) {
        ApplicationContext context = new AnnotationConfigApplicationContext(SpringConfiguration3.class);
        Pippo pippo = context.getBean(Pippo.class);
        pippo.start();
    }

}
@Path
@Component
public class ContactsController extends Controller {

    @Inject
    private ContactService contactService;

    @Inject
    private TemplateEngine templateEngine;

    @GET
    public void sayHello() {
        StringWriter writer = new StringWriter();
        Map<String, Object> model = new HashMap<>();
        model.put("name", "Decebal");
        templateEngine.renderString("Hello ${name}", model, writer);
        getResponse().send(writer.toString());
    }

}

@decebals
Copy link
Member Author

decebals commented Nov 20, 2021

For Guice Test, I modified pippo-demo-guice

public class GuiceApplication3 extends ControllerApplication {

    @Inject
    private List<? extends Controller> controllers;

    @Override
    protected void onInit() {
        // add routes for static content
        addPublicResourceRoute();
        addWebjarsResourceRoute();

        addControllers(controllers.toArray(new Controller[0]));
    }

}
public class GuiceModule3 extends AbstractModule {

    @Override
    protected void configure() {
        bind(ContactService.class).to(InMemoryContactService.class).asEagerSingleton();
        bind(Application.class).to(GuiceApplication3.class).asEagerSingleton();
        bind(Router.class).to(CustomRouter.class).in(Scopes.SINGLETON);
        bind(TemplateEngine.class).to(SimpleTemplateEngine.class).asEagerSingleton();
        bind(WebServer.class).to(TjwsServer.class).in(Scopes.SINGLETON);

        bind(Pippo.class);

        bindOptionalApplication();
        bindOptionalControllerApplication();
    }

    @Singleton
    @Provides
    @Inject
    public List<? extends Controller> controllers(ContactsController contacts) {
        return Arrays.asList(contacts);
    }

    private void bindOptionalApplication() {
        OptionalBinder.newOptionalBinder(binder(), ContentTypeEngines.class);
        OptionalBinder.newOptionalBinder(binder(), ErrorHandler.class);
        OptionalBinder.newOptionalBinder(binder(), HttpCacheToolkit.class);
        OptionalBinder.newOptionalBinder(binder(), Languages.class);
        OptionalBinder.newOptionalBinder(binder(), Messages.class);
        OptionalBinder.newOptionalBinder(binder(), MimeTypes.class);
        OptionalBinder.newOptionalBinder(binder(), Router.class);
        OptionalBinder.newOptionalBinder(binder(), WebSocketRouter.class);
        OptionalBinder.newOptionalBinder(binder(), RequestResponseFactory.class);
        OptionalBinder.newOptionalBinder(binder(), RoutePreDispatchListenerList.class);
        OptionalBinder.newOptionalBinder(binder(), RoutePostDispatchListenerList.class);
        OptionalBinder.newOptionalBinder(binder(), TemplateEngine.class);
        OptionalBinder.newOptionalBinder(binder(), new TypeLiteral<RouteHandler<?>>(){});
        OptionalBinder.newOptionalBinder(binder(), new TypeLiteral<List<Initializer>>(){});
    }

    private void bindOptionalControllerApplication() {
        OptionalBinder.newOptionalBinder(binder(), ControllerFactory.class);
        OptionalBinder.newOptionalBinder(binder(), ControllerInitializationListenerList.class);
        OptionalBinder.newOptionalBinder(binder(), ControllerInstantiationListenerList.class);
        OptionalBinder.newOptionalBinder(binder(), ControllerInvokeListenerList.class);
        OptionalBinder.newOptionalBinder(binder(), new TypeLiteral<List<MethodParameterExtractor>>(){});
    }

}
public class GuiceDemo3 {

    public static void main(String[] args) {
        Injector injector = Guice.createInjector(new GuiceModule3());
        Pippo pippo = injector.getInstance(Pippo.class);
        pippo.start();
    }

}
@Path
public class ContactsController extends Controller {

    @Inject
    private ContactService contactService;

    @Inject
    private TemplateEngine templateEngine;

    @GET
    public void sayHello() {
        StringWriter writer = new StringWriter();
        Map<String, Object> model = new HashMap<>();
        model.put("name", "Decebal");
        templateEngine.renderString("Hello ${name}", model, writer);
        getResponse().send(writer.toString());
    }

}

I don't like the complexity of GuiceModule3 (extra bindOptionalApplication and bindOptionalControllerApplication methods) but probably we can improve the code here (disclaimer: I'm not a good Guice connoisseur),

@mhagnumdw
Copy link
Member

    @Singleton
    @Provides
    @Inject
    public List<? extends Controller> controllers(ContactsController contacts) {
        return Arrays.asList(contacts);
    }

Sorry I didn't understand why ContactsController contacts. contacts ?!

What if I have dozens of controllers?

@mhagnumdw
Copy link
Member

@decebals , will you still make changes or can I test this version in my application?

@decebals
Copy link
Member Author

@decebals , will you still make changes or can I test this version in my application?

I think that you can test it. The code is perfect functional from what I see until now. Maybe little adjustments in time.

@decebals
Copy link
Member Author

decebals commented Nov 21, 2021

    @Singleton
    @Provides
    @Inject
    public List<? extends Controller> controllers(ContactsController contacts) {
        return Arrays.asList(contacts);
    }

Sorry I didn't understand why ContactsController contacts. contacts ?!

What if I have dozens of controllers?

Your question is good. In my last (relative big) project I use Pippo with Spring. In Spring this part with gathering all controllers and inject them in application is easy and automatically. All you have to do is to add Component annotation on each controller class and enable component scan ( @ComponentScan) in configuration.
In Guice I think that you need to register each controller by hand, no support for component scan in core (at least as far as I know). I am not a Guice guy and I am sure that the example code presented by me related to Guice can be improved.
Maybe there's someone here who can help us.

@decebals
Copy link
Member Author

Your question is good. In my last (relative big) project I use Pippo with Spring. In Spring this part with gathering all controllers and inject them in application is easy and automatically. All you have to do is to add Component annotation on each controller class and enable component scan ( @ComponentScan) in configuration. In Guice I think that you need to register each controller by hand, no support for component scan in core (at least as far as I know). I am not a Guice guy and I am sure that the example code presented by me related to Guice can be improved. Maybe there's someone here who can help us.

In the end I think that I solved the problem in an elegant way using Guice Multibinding and Reflections.
Now the code looks like:

public class GuiceModule3 extends AbstractModule {

    @Override
    protected void configure() {
        bind(ContactService.class).to(InMemoryContactService.class).asEagerSingleton();
        bind(Application.class).to(GuiceApplication3.class).asEagerSingleton();
        bind(Router.class).to(CustomRouter.class).in(Scopes.SINGLETON);
        bind(TemplateEngine.class).to(SimpleTemplateEngine.class).asEagerSingleton();
        bind(WebServer.class).to(TjwsServer.class).in(Scopes.SINGLETON);

        bind(Pippo.class);

        bindControllers();

        bindOptionalApplication();
        bindOptionalControllerApplication();
    }

    private void bindControllers() {
        // retrieve controller classes
        Reflections reflections = new Reflections(getClass().getPackage().getName());
        Set<Class<? extends Controller>> controllers = reflections.getSubTypesOf(Controller.class);

        // bind found controllers
        Multibinder<Controller> multibinder = Multibinder.newSetBinder(binder(), Controller.class);
        controllers.forEach(controller -> multibinder.addBinding().to(controller));
    }

}
public class GuiceApplication3 extends ControllerApplication {

    @Inject
    private Set<Controller> controllers;

    @Override
    protected void onInit() {
        // add routes for static content
        addPublicResourceRoute();
        addWebjarsResourceRoute();

        addControllers(controllers.toArray(new Controller[0]));
    }

}

I tested with multiple controllers and the result is good.

@decebals decebals marked this pull request as ready for review November 21, 2021 10:19
@mhagnumdw
Copy link
Member

I already use the Reflections lib and it is very good!

@mhagnumdw
Copy link
Member

@decebals , will you still make changes or can I test this version in my application?

I think that you can test it. The code is perfect functional from what I see until now. Maybe little adjustments in time.

My boot is heavily modified, so for now I won't be able to test it thoroughly. But I have good news: with this version my application continues to work normally.

@mhagnumdw
Copy link
Member

I'm trying to adapt my application to this model...

@decebals , I use the EntityManager configured via the com.google.inject.persist.jpa.JpaPersistModule.JpaPersistModule Guice module.

The JpaPersistModule receives a set of properties via the properties(Map<?,?> properties) method. I got these properties from the ro.pippo.core.Application.getPippoSettings() instance. Do you have any idea what the best way to do this is now?

Application.getPippoSettings() will not be available when creating the Guice module.

@mhagnumdw
Copy link
Member

For Guice Test, I modified pippo-demo-guice

public class GuiceApplication3 extends ControllerApplication {

    @Inject
    private List<? extends Controller> controllers;

    // ...
}

For me, Guice's dependency injection just only worked like this:

@Inject
private Set<Controller> controllers;

obs: Set or List

@decebals
Copy link
Member Author

obs: Set or List

Yes, it's Set instead of List. Sorry for inconvenient.
I will update the snippet code.

@decebals
Copy link
Member Author

In #590 (comment), it's Set instead of List. Probably you copied the initial code.

@decebals
Copy link
Member Author

decebals commented Nov 26, 2021

I'm trying to adapt my application to this model...

@decebals , I use the EntityManager configured via the com.google.inject.persist.jpa.JpaPersistModule.JpaPersistModule Guice module.

The JpaPersistModule receives a set of properties via the properties(Map<?,?> properties) method. I got these properties from the ro.pippo.core.Application.getPippoSettings() instance. Do you have any idea what the best way to do this is now?

Application.getPippoSettings() will not be available when creating the Guice module.

I inject PippoSettings in Spring also, together with Application and other services. When I need PippoSettings I injected where I need it.
My approach with application.properties is hybrid, the same file is used by PippoSettings (internal stuff like server port, ..) but it is also used by Spring. I injected the properties in my services via Spring @Value annotation. So, if I need one or more properties in a service (or other component outside Pippo), I don't retrieve that information via PippoSettings, but using the DI container support for properties.

As I mentioned in #565, the pippo - spring integration is good enough for me and without this PR. This PR is useful when you want to fine tuning the pippo stack from DI (Spring, Guice), entirely.

@decebals
Copy link
Member Author

decebals commented Nov 26, 2021

I obtained relative (only one problem related to gathering all controllers in a set/list collection) good result with Avaje Inject library. It's a dependency injection library inspired by Dagger2, with very good performance and a small size (48 KB - version 5.13). It's useful when the total size of application matters, but you want to use a DI container.

The code in this case looks like:

//@Singleton
public class AvajeApplication extends ControllerApplication {

    private List<Controller> controllers;

//    @Inject
    public AvajeApplication(List<Controller> controllers) {
        this.controllers = controllers;
    }

    @Override
    protected void onInit() {
        // add routes for static content
        addPublicResourceRoute();
        addWebjarsResourceRoute();

        addControllers(controllers.toArray(new Controller[0]));
    }

}
@Factory
public class AvajeConfiguration {

    @Bean
    public ContactService contactService() {
        return new InMemoryContactService();
    }

    @Bean
    public PippoSettings pippoSettings() {
        return new PippoSettings();
    }

//    @Bean
//    public List<Controller> controllers(ContactsController contactsController) {
//        System.out.println("AvajeConfiguration.controllers");
//        return Collections.singletonList(contactsController);
//    }

    @Bean
    public Application application(ContactsController contactsController, TestController testController) {
        return new AvajeApplication(Arrays.asList(contactsController, testController));
    }

//    @Bean
//    public Pippo pippo(Application application, WebServer webServer) {
//        return new Pippo(application).setServer(webServer);
//    }

}
public class AvajeDemo {

    public static void main(String[] args) {
        BeanScope beanScope = BeanScope.newBuilder().build();
        Pippo pippo = beanScope.get(Pippo.class);
        pippo.start();
    }

}
@Path
@Singleton
public class ContactsController extends Controller {

    @Inject
    ContactService contactService;

    @Inject
    TemplateEngine templateEngine;

    @GET
    public void index() {
        getResponse().bind("contacts", contactService.getContacts());
        getResponse().render("contacts");
    }

}

@decebals decebals closed this Nov 26, 2021
@decebals decebals reopened this Nov 26, 2021
@mhagnumdw
Copy link
Member

mhagnumdw commented Nov 26, 2021

I'm trying to adapt my application to this model...
@decebals , I use the EntityManager configured via the com.google.inject.persist.jpa.JpaPersistModule.JpaPersistModule Guice module.
The JpaPersistModule receives a set of properties via the properties(Map<?,?> properties) method. I got these properties from the ro.pippo.core.Application.getPippoSettings() instance. Do you have any idea what the best way to do this is now?
Application.getPippoSettings() will not be available when creating the Guice module.

I inject PippoSettings in Spring also, together with Application and other services. When I need PippoSettings I injected where I need it. My approach with application.properties is hybrid, the same file is used by PippoSettings (internal stuff like server port, ..) but it is also used by Spring. I injected the properties in my services via Spring @Value annotation. So, if I need one or more properties in a service (or other component outside Pippo), I don't retrieve that information via PippoSettings, but using the DI container support for properties.

As I mentioned in #565, the pippo - spring integration is good enough for me and without this PR. This PR is useful when you want to fine tuning the pippo stack from DI (Spring, Guice), entirely.

Hi! I understand that it is possible to inject PippoSettings into a Guice component. But this only works after Guice is ready. In my case I would need PippoSettings when creating the Guice modules.

To explain it better, something like this:

Injector injector = Guice.createInjector(
    new PippoGuiceModule(),
    new AppJpaPersistModule("persistenceUnitName", pippoSettings), // <<< need PippoSettings instance here
    new AppGuiceModule()
);

GuiceInjector.set(injector);
Pippo pippo = injector.getInstance(Pippo.class);

pippo.start();

ps: I'm looking for a way to work around this problem.

@mhagnumdw
Copy link
Member

    private void bindControllers() {
        // retrieve controller classes
        Reflections reflections = new Reflections(getClass().getPackage().getName());
        Set<Class<? extends Controller>> controllers = reflections.getSubTypesOf(Controller.class);

        // bind found controllers
        Multibinder<Controller> multibinder = Multibinder.newSetBinder(binder(), Controller.class);
        controllers.forEach(controller -> multibinder.addBinding().to(controller));
    }

We might have abstract controllers, so maybe it's better to avoid bind errors (at least in Guice):

Reflections reflections = new Reflections(getClass().getPackage().getName(), new SubTypesScanner());
Set<Class<? extends Controller>> controllers = reflections.getSubTypesOf(Controller.class)
    .stream()
    .filter(clazz -> clazz.isAnnotationPresent(ro.pippo.controller.Path.class))
    .collect(Collectors.toSet())

Or some other logic that checks if it's a concrete class.

@decebals
Copy link
Member Author

Hi! I understand that it is possible to inject PippoSettings into a Guice component. But this only works after Guice is ready. In my case I would need PippoSettings when creating the Guice modules.

To explain it better, something like this:

Injector injector = Guice.createInjector(
    new PippoGuiceModule(),
    new AppJpaPersistModule("persistenceUnitName", pippoSettings), // <<< need PippoSettings instance here
    new AppGuiceModule()
);

GuiceInjector.set(injector);
Pippo pippo = injector.getInstance(Pippo.class);

pippo.start();

ps: I'm looking for a way to work around this problem.

I don't visualize your implementation. How AppJpaPersistModule looks like?

@decebals
Copy link
Member Author

Hi! I understand that it is possible to inject PippoSettings into a Guice component. But this only works after Guice is ready. In my case I would need PippoSettings when creating the Guice modules.

What about https://stackoverflow.com/questions/39734343/injecting-a-dependency-into-guice-module?

@mhagnumdw
Copy link
Member

I don't visualize your implementation. How AppJpaPersistModule looks like?

Oh, sorry 😅, I forgot to mention it's a class of mine. It's just a wrapper for Guice's JpaPersistModule.

It goes something like this:

public class AppJpaPersistModule implements Module {

    private final PippoSettings settings;

    public JPAGuiceModule(PippoSettings settings) {
        this.settings = settings;
    }

    @Override
    public void configure(Binder binder) {
        JpaPersistModule jpaModule = new JpaPersistModule(Constantes.PU_NAME);
        jpaModule.properties( ... ); // TODO: get properties from PippoSettings and add here
        binder.install(jpaModule);
    }

}

ps: But I think I'll change the strategy so I don't need PippoSettings there... I'm still not sure what it's going to look like... I'm still seeing it...

@mhagnumdw
Copy link
Member

@decebals , I use Freemarker and it's not working. The problem is that the ro.pippo.freemarker.FreemarkerTemplateEngine.init(Application) method is not being called.

To make it work I did:

  • Annotated the ro.pippo.core.AbstractTemplateEngine.init(Application) method with @javax.inject.Inject
  • Annotated the ro.pippo.freemarker.FreemarkerTemplateEngine.init(Application) method with @javax.inject.Inject

I configure the Guice module like this:

@Override
protected void configure() {
    bind(Application.class).to(PippoApplication.class).asEagerSingleton();
    bind(TemplateEngine.class).to(FreemarkerTemplateEngine.class).asEagerSingleton();
    // ...
}

It would be nice to be able to leave the annotation just on the AbstractTemplateEngine class.

@decebals
Copy link
Member Author

It would be nice to be able to leave the annotation just on the AbstractTemplateEngine class.

@mhagnumdw
I think that I have a solution based on #591. Please review #591 and if you consider that is OK I can merge it in master branch (and in inject branch) and I will continue work on this PR.

# Conflicts:
#	pippo-controller-parent/pippo-controller/src/main/java/ro/pippo/controller/ControllerApplication.java
@mhagnumdw
Copy link
Member

@decebals , please update from master.

# Conflicts:
#	pippo-controller-parent/pippo-controller/src/main/java/ro/pippo/controller/ControllerApplication.java
@decebals
Copy link
Member Author

decebals commented Dec 1, 2021

@decebals , please update from master.

Done.

@mhagnumdw
Copy link
Member

@decebals , please update from master.

@decebals
Copy link
Member Author

@decebals , please update from master.

Done

@mhagnumdw
Copy link
Member

Currently I register a content type like this: registerContentTypeEngine(GsonEngine.class).

What do you think we also use dependency injection to register ContentTypeEngine?

In Guice we can use MapBinder, in Spring I think it's also called MapBinder.

If you agree, could you implement it? So I would validate doing the tests in my application.

@decebals
Copy link
Member Author

If you agree, could you implement it? So I would validate doing the tests in my application.

Sure, I will do it. Now I am in a mini vacation with family.

@decebals decebals self-assigned this Nov 21, 2022
@decebals decebals added this to the Pippo 2 milestone Nov 21, 2022
@sonarcloud
Copy link

sonarcloud bot commented Nov 22, 2022

Kudos, SonarCloud Quality Gate passed!    Quality Gate passed

Bug A 0 Bugs
Vulnerability A 0 Vulnerabilities
Security Hotspot A 0 Security Hotspots
Code Smell A 0 Code Smells

No Coverage information No Coverage information
0.0% 0.0% Duplication

@decebals
Copy link
Member Author

decebals commented Feb 4, 2023

@mhagnumdw I don't know if you abandoned the ship but I will write here some of my conclusions :).
I found a method less intrusive based on Nullable annotation.
So, for each field that is a possible candidate for injection from outside (application) I added two annotations:

import javax.annotation.Nullable;
import javax.inject.Inject;

class MyClass {

    @Inject @Nullable
    private Greeting greeting;

}

Both Spring and Guice know how to deal with this combination of annotations.
By the way, a similar approach is available for setter:

import javax.annotation.Nullable;
import javax.inject.Inject;

class MyClass {

    private Greeting greeting;

    @Inject
    public void setGreeting(@Nullable Greeting greeting) {
        this.greeting = greeting;
    }
}

What I don't like is that in all situations (our initial solution based on Optional, the solution based on Nullable on field or setter parameter), in Guice (only here), you (the developer) are forced to inject something (via OptionalBinder or Providers.of(null)). For example see the snippet code from #590 (comment), bindOptionalXYZ are relative big and that doesn't smell good to me.

And this problem is because javax.inject.Inject doesn't come with support for optional.
The only cleaner solution for both Spring and Guice (for Guice in particular, because I have no problems in Spring) is to add both @Autowire (Spring) and @com.google.inject.Inject (Guice) on fields:

import org.springframework.beans.factory.annotation.Autowired;
import com.google.inject.Inject;

class MyClass {

    @Autowired(required = false)
    @Inject(optional = true)
    private Greeting greeting;

}

but in this case we must add guice and spring-beans as dependencies ONLY for compile phase (Java is OK if it doesn't find the annotations declared in import section at runtime).

Everything started from the idea to have something/everything configurable via most popular java IoC (Spring and Guice), but without forcing the pippo developer to use IoC (or a specific IoC).

@mhagnumdw
Copy link
Member

Hi @decebals !!

I paused this activity for timing reasons. I still think it's a worthwhile activity. But unfortunately, given its magnitude, I won't have time to see it carefully.

My application that uses Pippo is only receiving fixes and they are sporadic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants