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

RFC: Http interceptors and transformers #2684

Closed
jeffbcross opened this issue Jun 22, 2015 · 97 comments
Closed

RFC: Http interceptors and transformers #2684

jeffbcross opened this issue Jun 22, 2015 · 97 comments

Comments

@jeffbcross
Copy link
Contributor

This issue is intended to define requirements for how the Http library should support common operations, like request and response transformations and error handling. The issue doesn't yet have a proposed design, but discussion, comments and suggestions are welcomed. Un-captured use cases are especially welcomed.

History

Two widely-used feature of angular1 are request and response interceptors and request/response transformers. Interceptors and Transformers have different goals and constraints.

  • Interceptors are expected to have side effects, while transformers' exclusive purpose is to mutate requests or responses synchronously.
  • Interceptors may be asynchronous, tranformers must be synchronous
  • Interceptors are global and configured in Angular 1's "compile" phase, while transformers may be global or local. Global transformations can be mutated in the "run" phase.
  • Error interceptors are declared explicitly and separately from normal interceptors, whereas transforms handle normal and error cases.
  • Error interceptors can recover from errors by returning a resolved promise

Some common use cases for interceptors:

  • Setting timeouts for requests
  • Setting custom XSRF tokens to request headers, as described in $http docs
  • Add conditional parameters to all request urls (like an access token, or a/b-testing info)
  • Check authentication state before performing request, prompt user to authenticate
  • Have side effects on other parts of application (i.e. if response says user needs to authenticate, route them to login page)
  • Log all response errors returned from the server to a persisted log
  • Log total request/response time in order to do performance analysis
  • Filter out un-interesting request/responses from logs
  • Recover from request exceptions, or response errors (i.e. if request times out, return old data from cache)
  • Unwrapping responses (i.e. if response is {data: {foo:bar}}, return response.data)
  • Cache responses in a custom cache to be used by other services

Common use cases for transformers

  • serialize/de-serialize data, both directions
  • Strip headers (Angular 1 strips user-specified XSRF header with a default response transformation)

Worth noting are some additional features provided by Angular 1 $http, which would otherwise be handled by transformers:

  • Url-based caching
  • Configurable parameter serialization
  • Header serialization

New Considerations for Angular 2’s Http Library

Angular 2 and Angular 2 Http have some technical differences and philosophical differences from Angular 1.

  • Mutable global state is discouraged (i.e. $http.defaults.transforms.push(...)).
  • Angular 2 has no "phases" like "config" and "run".
  • Angular 2 provides hierarchical dependency injection, allowing users to modify bindings at different levels of an application's component tree.
  • Angular 2 Http is based primarily on Observable instead of Promise
  • Http has goals of supporting upload/download progress events, connection retrying, request cancellation, caching, and polling as first-class use cases.

Current Options

The current library supports creating services that would provide shared interceptors and transformations. Here's an example of an Http-based service that would add custom headers, cache responses and log errors, based on the current RxJS implementation.

import {Http, Request, IRequestOptions} from 'angular2/http';
import {ResponseCache} from './my-response-cache';
import {ErrorLog} from './my-error-log';
class MyConfiguredHttp {
    constructor(public http:Http, public responseCache:ResponseCache, public errorLog:ErrorLog) {}
    request(options:IRequestOptions) {
        if (options.url in this.responseCache){
            //Naive example (This Observable create syntax not yet supported)
            //There's a cached response for this url, return it instead of performing request
            return Rx.Observable.from([this.responseCache[options.url]]);
        }

        var req = new Request(options);
        // Set a custom auth header
        req.headers.set('sessionid', '123');
        return this.http.request(req)
            //Try performing the request up to three times
            .retry(3)
            .do(res => {
                //Naive example, cache the response by the original url
                this.responseCache[options.url] = res;
            })
            .doOnError(res => {
                // Log the error with my app's logging service
                this.errorLog.push(options, res);
            });
    }   
}

Questions to answer with final design:

  • What use cases are not able to be supported by the "shared service" approach?
  • How can the API be improved to make performance, robustness, and security the default path?
  • How is composability impacted by this approach, and how can it be improved?
  • How could this be simple and intuitive?
  • Does this require too deep of knowledge of Observables/Rx?
  • Does anyone actually read this far down on Github issues?
@robwormald
Copy link
Contributor

This sort of approach makes a lot of sense to me, and generally just feels more composable and less configure-all-the-things. All for it.

Might be worthwhile looking at doing the same sort of thing $http does, where the .then( returns the raw response, but has a couple of helper methods ( .success / .error ) which purely return the response data vs the whole response...

http.request('foos.json').success().subscribe(....
http.request('foos.json').map(responseTransformer).subscribe(...

both read nicely for me. just comes down to edumacatin' everybody on the mechanics of Observables.

@jeffbcross jeffbcross mentioned this issue Jun 30, 2015
35 tasks
@benlesh
Copy link
Contributor

benlesh commented Jul 6, 2015

If you wanted to do some sort of global transformers, you could have some globally registered function, but rather than just a mapping function, you could use a flatMapping function that gives you a stream of requests, and you output a stream of modified requests. i.e.:

Config.requestIntercept = (requests) => {
  return requests.flatMap(setHeaders);
};

this would allow either synchronous or asynchronous actions to occur to do things like set headers. Currently, in Angular 1, something like this would be impossible, AFAIK.

@benlesh
Copy link
Contributor

benlesh commented Jul 6, 2015

Further thought on this: Presuming that each request was an observable itself, you could actually use an Rx mechanism (the timeout operator) to introduce a timeout with this design, if you so chose:

Config.requestIntercept = (requests) => requests.flatMap(req => req.timeout(5000))

@robwormald
Copy link
Contributor

I'd definitely like to see more "compose / wrap it yourself" APIs vs yet-another-thing-that's-angular2-specific-that-you-have-to-look-up-in-the-docs - the current API for interceptors is a bit of a nightmare (create a factory that you register with a provider in a config block) and as @jeffbcross said, mutable global state ftl... I often talk to a couple of different APIs from an app, and global interceptors make that more complex.

@benlesh
Copy link
Contributor

benlesh commented Jul 7, 2015

@robwormald you're probably right. In fact, it might be better advice to tell people to compose over their requests themselves by wrapping the http service.

In fact, I think I'd like to 👎 this feature in favor of advising people to wrap the http service and use that.

@benlesh
Copy link
Contributor

benlesh commented Jul 7, 2015

... which is exactly what @jeffbcross had said to begin with. :P haha

@jeffbcross
Copy link
Contributor Author

Ha, well I'd like to make sure we do due diligence, and explore ways we can make these things clear and simple, short of saying "go watch all of @Blesh's RxJS tutorials, then create some wrapper services, and you're done." I tend to lean away from the declarative, "everything-on-config" approach of Angular 1, but still think there may be helpers that could be added to reduce the bar of prerequisite Rx knowledge to do common things.

@jeffbcross jeffbcross self-assigned this Jul 7, 2015
@robwormald
Copy link
Contributor

is it heresy to suggest we provide an HttpClient base class one could extend and configure themselves?

something like

class MyHttpClient extends BaseHttpClient {
  transformResponse(res){  //do custom stuff here? } 
  transformRequest(req){ //do custom stuff here? }
}

myHttpClient.get('foos.json').subscribe(...)

I know I just said composition, and I prefer the wrap-it method, but it seems the choices are:

  • extensible base class
  • teach 'em how to wrap
  • explaining how to do all the bind() jazz to override some injectable config.

@benlesh
Copy link
Contributor

benlesh commented Jul 7, 2015

Perhaps helpers could come in the flavor of helpers to extend our wrap the service? I'm not sure what this looks like, just spitballing.

@lehmamic
Copy link

lehmamic commented Jul 7, 2015

I would prefer a general interception mechanism in Angular2 which is not only dedicated to an http service. This would allow a lot more possibilities.

I entered an issue for that yesterday #2893

Wrapping a service would make sense if you do this for only one service, but this is not very productive if you need to do this for many services, e.g. adding automatic logging, exception handling etc.

@thelgevold
Copy link
Contributor

It's been a while since I looked at http in Angular 2.0. But after digging through the Angular source code, and doing a POC for my blog, my impression was that it follows some variation on this pattern:

http://www.syntaxsuccess.com/viewarticle/angular-2.0-and-http

Is this pattern still part of the vision?

My initial stab at this was very simplistic and did not expose me to concepts like error handling, chaining or parallel requests. Surely there is a plan for this, but it's not 100% clear to me how it will look syntactically. Maybe not even conceptually :-)

I find the current $q based solution to be very attractive when it comes to chaining, error handling and parallel requests using $q.all(..) etc. Another big advantage is that it aligns well with the promise standard know by most developers. I know rx.js pre-dates Angular, but I am worried that it's not as widely used as the promise model and might be seen as an Angular "odd man out" approach.

Given the huge pivot of Angular 2.0 in general I think one should be allowed to expect a very compelling argument in favor of change before making changes to things that might not even be broken. Not saying such an argument can't be produced, but at the same time, I am very interested in hearing why Angular 2.0 decided to move away from $q and $http.

I suspect there are some cool features built into rx since you decided to go in that direction. However, I think it's important to not forget that the 90% use case for http calls is most likely hitting some api and request some simple json data. My point is that there is a trade off between forcing everyone to reeducate themselves only to gain access to features that might already be solved well in the current, simple well known approach.

That said, I am not against change, just want to learn more about the reasoning behind it.

@robwormald
Copy link
Contributor

Hey Torgeir

Thanks for the input - I'll write a more in depth response later, but have a look at this plunk and it might answer some of your initial questions : http://plnkr.co/edit/ul0NVGadmGi3v6nywGRw

On Sep 12, 2015, at 1:15 PM, Torgeir Helgevold notifications@github.com wrote:

It's been a while since I looked at http in Angular 2.0. But after digging through the Angular source code, and doing a POC for my blog, my impression was that it follows some variation on this pattern:

http://www.syntaxsuccess.com/viewarticle/angular-2.0-and-http

Is this pattern still part of the vision?

My initial stab at this was very simplistic and did not expose me to concepts like error handling and chaining or parallel requests. Surely there is a plan for this, but it's not 100% clear to me how it will look syntactically. Maybe not even conceptually :-)

I find the current $q based solution to be very attractive when it comes to chaining, error handling and parallel requests using $q.all(..) etc. Another big advantage is that it aligns well with the promise standard know by most developers. I know rx.js pre-dates Angular, but I am worried that it's not as widely used as the promise model and might be seen as an Angular "odd man out" approach.

Given the huge pivot of Angular 2.0 in general I think one should be allowed to expect a very compelling argument in favor of change before making changes to things that might not even be broken. Not saying such an argument can't be produced, but at the same time, I am very interested in hearing why Angular 2.0 decided to move away from $q and $http.

I suspect there are some cool features built into rx since you decided to go in that direction. However, I think it's important to not forget that the 90% use case for http calls is most likely hitting some api and request some simple json data. My point is that there is a trade off between forcing everyone to reeducate themselves only to gain access to features that might already be solved well in the current, simple well known approach.

That said, I am not against change, just want to learn more about the reasoning behind it.


Reply to this email directly or view it on GitHub.

@thelgevold
Copy link
Contributor

Thanks @robwormald I will definitely take a look at that plunk!

@e-oz
Copy link

e-oz commented Sep 13, 2015

I'm glad to see this approach, when we use DI instead of global changes. To make this approach work with 3-party libraries too, we need to encourage them to require Http as dependency (if they need Http to work).

Might be worthwhile looking at doing the same sort of thing $http does, where the .then( returns the raw response, but has a couple of helper methods ( .success / .error ) which purely return the response data vs the whole response...

Please don't make this mistake again. $http.success/error is a very handy thing but it breaks the API signature - gives wrong expectation. For example, in first version of your component you return directly result of $http.get, client code written with .success/.error, and in later versions of component you find you need to return usual promise, without .success/.error functions - so you have 2 choices: breaking change or own implementation of .success/.error to your promise, just for few clients.

So please follow observables API, without additions.

@0x-r4bbit
Copy link
Contributor

I agree with @e-oz here to not introduce sth. like .success() and .error(). Those have also been deprecated in Angular 1.4 for the reasons @e-oz mentioned.

@robwormald
Copy link
Contributor

The difference with the success/error idea in observable is that it's an operator - and adding operators is completely acceptable in Observable land (rxnext has a specific API for extending) - vs the terminator.

  • http.get(url).filter(res => res.statusCode > 200).map(res => res.json()).subscribe(onSuccess)
  • http.get(url).success().subscribe(onSuccess)
  • http.get(url).toJSON().subscribe(onSuccess)

Note that you're calling .subscribe() in all cases there (equiv to .then). I do take your point tho. Thanks for the input, keep it coming!

@mkretz
Copy link

mkretz commented Oct 31, 2015

@jeffbcross one use case to consider might be the following: using $http interceptors in Angular 1 you can ensure that certain behaviour (e.g. redirect for authentication) is there by default, without developers having to remember to use a particular wrapper or shared service. Would this kind of default behaviour still be possible in Angular 2 using the wrapper approach? I am guessing you can tell DI to use the wrapper whenever Http is injected, right (do not know Angular 2 well enough yet)?

@piernik
Copy link

piernik commented Nov 15, 2016

I'm also looking for easy, angular1 like, solution.

@squadwuschel
Copy link

squadwuschel commented Nov 17, 2016

is there a out of the box solution for Interceptors on the roadmap for ng2 or do I have to solve the problem for my own?

I've solved it with this Solution: https://squadwuschel.wordpress.com/2017/01/21/http-interception-in-angular-2/

@DzmitryShylovich
Copy link
Contributor

you have to solve it yourself

@witoldsz
Copy link

The problem with DIY solutions is that they are not going to be compatible with each other…

@kentoj
Copy link

kentoj commented Nov 22, 2016

Here is an excellent conversion of Ben Nadel's custom HTTP client.
https://blog.sstorie.com/adapting-ben-nadels-apigateway-to-pure-typescript/

@witoldsz It seems like DIY solutions are not supposed to be compatible with each other for the most part because they are specific to the project. For example, security config for headers and tokens will differ across projects. However, there are somethings that could be common, such as some of the functions in the example I linked like URL path variable parsing, adding content type, and adding a xsrf cookie.

Edit (11/27/16): I was able to use a modified version of Sam Storie's Typescript HTTP Client to great advantage. It is simpler than I first thought, especially for things like adding a HTTP Basic Auth header.

@voliva
Copy link

voliva commented Nov 22, 2016

@kentoj I tried creating a npm package to provide an interface for registering individual interceptors https://github.com/voliva/angular2-interceptors. The main idea is to have a structure that would support this kind of use case, and then you'd have interceptors specific for each project, although many of them could be shared.

I'm looking forward to improve my solution, so any kind of feedback is welcome :)

@LiorArbel
Copy link

@MarkPieszak Hey Mark just wanted to bring to your attention that issue #80 doesn't exists, might be mainly because the angular/http repository doesn't exist (or is private?) currently.

I beg you, please provide some kind of a simple solution and\or check what is going on with the http repository... I really really don't want to rewrite so much of the http class for such simple functionality... what do we have the DRY rule for??

@MarkPieszak
Copy link
Member

It went private then I believe was merged into the main Angular repo.

I believe (but I'm not sure) that at least the current goal is to leave it up to the user to override or implement Http and wrap things that way. But I do agree, it would be nice to keep things consistent between ng1&2 in this regard.

What are you currently doing to achieve this? There's a lot of options online to choose from as well.

@kentoj
Copy link

kentoj commented Nov 28, 2016

@LiorArbel and @MarkPieszak At first glance it definitely seemed like a violation of DRY until I did my own implementation of an HTTP client. What it ended up being was a service for accessing my backend API gateway.

Now that my first implementation is done I could see a valid use case for creating a custom HTTP client for every API gateway my application interacts with. Why? the authentication may be different, the content type may be different (XML or JSON), one may implement hypermedia (HATEOAS) and one may not, and so forth.

Creating a global HTTP interceptor could make things harder for me if I interact with multiple APIs that have different requirements. This could be the 80/20 rule though. If 80% of angular 2 apps only communicate to one backend API then a global interceptor may not be so bad.

The HTTP interceptor module, if created, seems like a module that could be added in and used conditionally, similar to how Spring Java uses the @Conditional annotation to conditionally wire up and configure components based on whether they are present.

At the very least we should point from the official docs to an example or at least the idea of a custom HTTP client, the one I mentioned from Sam Storie earlier being a good candidate.

@witoldsz
Copy link

@kentoj this is so much the "my point of view is better than yours", actually. You have some special setup for which the "global" HTTP interceptor would be less optimal than your own custom HTTP service wrapper. That is fine, but having an option to register/plug-into the official HTTP service would suit others just well, would allow for a 3rd party market of extensions (like it's now for AngularJS 1), and you could still choose your own wrapper solution.

@kentoj
Copy link

kentoj commented Nov 28, 2016

@witoldsz How do you propose to implement the option of registering an interceptor or plugging it in? Since, as the OP stated, Angular 2 has no "phases" like "config" and "run" then as Angular currently exists you would have to ensure the interceptor was registered before any HTTP calls were made.

Or, you could do something like what @voliva did that ends up with injection of an interceptor-aware wrapper for HTTP:

    constructor(
        private http: InterceptorService) {
    }

Without adding something like "config" or "run" it looks like the best option for interceptors in Angular 2 HTTP is a wrapper, whether that be a custom HTTP client or an interceptor-aware extension to HTTP, which is a custom HTTP client. So either way, it looks like a custom HTTP client of some sorts is the way to go unless you propose adding some config phase to the angular 2 bootstrapper or dependency injector.

@LiorArbel
Copy link

LiorArbel commented Nov 29, 2016

@kentoj let me be a bit rude here...
I don't see how it remotely matters that there are so many niche use cases for an http class - why would the addition of an optional ability to add an interceptor even matter to you???

On the contrary, it would only help all those tons of people that wrapped the Http class and wrote tons of coupled code to add such a simple feature from angular 1 that involves about 10 lines of code.

I mean, even auth0 ended up rewriting all the Http class' function signatures ,
I can only imagine how many angular 2 projects would get invalid if one of these functions would change.

@kentoj
Copy link

kentoj commented Nov 29, 2016

@LiorArbel I appreciate the energy. You make a good point on coupling to the Http class's API.

I make no argument against adding an interceptor. Furthermore, I would probably use the interceptor functionality if it existed. I must be coming across as against interceptors. I am not against them at all. They were the first thing that I looked for beyond simple HTTP in an angular app. When I first learned I would have to subclass HTTP to add my functionality I was very displeased because of the added complexity, coupling, and class inheritance. Avoid class-based inheritance except when I know for sure it is the best tool for the job seems to keep me out of inheritance hierarchy troubles.

Regarding custom HTTP clients I only acknowledge the usefulness of a custom HTTP client and the value I got from having to write one myself (copy from Sam Storie and Ben Nadel :] ). Once I wrote my custom HTTP client I felt like I had more control and understanding of the underlying process. It feels like I can contain the leaks of the network and HTTP abstractions in one place instead of everywhere I need to make an HTTP call.

Additionally, when I implement HATEOAS since HATEOAS is a protocol for defining domain-specific languages in the future I see myself handling that protocol in a custom HTTP client and not an interceptor since the scope for that protocol management goes beyond simple interception. Since some of my backend microservices may not yet be using HATEOAS I may need a custom HTTP client for each microservice depending on its maturity level.

Even still, I can see myself using an HTTP interceptor for all requests for authentication since I expect authentication to be globally the same for one client application.

Comparing the coupling of the custom HTTP client to an interceptor API makes it seem like using an interceptor would only be coupled to one method, the interceptor registration call.

And yet, the claim I previously made still stands. How would we add the interceptor functionality to Angular 2 without adding bootstrap or config phases? Bootstrap and config phases could be the right answer. Spring does a similar thing when doing dependency injection for a Spring Java app. Maybe the right place to add bootstrap or config for Angular 2 is in the dependency injector. That may or may not be an option for Angular 2. Who is the creator of the injector?

@ziv
Copy link

ziv commented Nov 29, 2016

Although I found HTTP interceptors very useful in Angular 1, there are also some drawbacks for using global interceptors. For example, I used interceptors to manage the "login" status of the user, but! what happened when you're dealing with the login request itself? Should it "trap" using the global interceptor?

I found that for complicated apps, there are several types of HTTP connections I want to handle and a global one break this approach. So, instead of manipulating the Http class itself, I have several "backend" classes that handle HTTP requests, each one responsible for its specific task (checking for login during request, handling JSON, etc.)

Changing the HTTP client itself, limit your application to create different request types.

@DzmitryShylovich
Copy link
Contributor

They have ideas to introduce something like HttpService based on fetch api (when they add abort whatwg/fetch#27). With async json parsing, support for interceptors etc. But there're some problems with fetch like it doesn't support progress at all JakeChampion/fetch#89.

@LiorArbel
Copy link

@DzmitryShylovich Thank you very much, it's nice to know. Would be happy if you could keep us updated.

@kentoj Must we have an initialization\bootstrap\config\w.e. stage? What if the Http service would be a stateful service that holds interceptors that can be added at runtime?

And then if we take for example the authentication token header case - only after the usser logged to your server you will then push the interceptor which handles the authentication token headers.
(I have never read a single line of the angular 2 implementation so I have no iead if that is actually feasible, hope I'm not embarrassing myself (: )

@gund
Copy link

gund commented Feb 22, 2017

@LiorArbel good point about coupling with Http methods!
That is why I used Proxy to not to worry about Http implementation at all while building ng-http-interceptor =)

@DzmitryShylovich
Copy link
Contributor

Interceptors will be available in ~4.1
https://docs.google.com/document/d/1C_wzuuQEjIktJxrTlUTjTCDEZR8YduGTfbpe4WsNfNk/edit#heading=h.xgjl2srtytjt

@e-cloud
Copy link

e-cloud commented Apr 25, 2017

@DzmitryShylovich It seems the proposal is obsolete.

What's the status of it?
Is there a new proposal?
Or just no interceptor feature in the near future?

@BorntraegerMarc
Copy link

Is there an update? The Doc from @DzmitryShylovich is "obsolete"

@bgever
Copy link

bgever commented May 18, 2017

@jeffbcross Could you please update on the status?

The regression vs Angular 1.x of not having interceptors and transformers is breaking essential functionality we need to:

  1. Make even a simple API call with an Authorization header.
  2. Logging errors.
  3. Handling retries for flaky connections (China).

To be frank, I'm stunned that the framework still does not support this and that Http is marked experimental. This has been solved years ago in v1, and XHRs are so fundamental to a SPA.

Currently the status of this feature is in limbo:

  1. This issue is marked Closed.
  2. The backlog is Closed and outdated.
  3. The referenced design doc is marked obsolete.
  4. There is no open tagged issue related to this.

I don't know whether we are forced to make our own Http wrapper with this basic functionality and have to update all our dependencies. And then, when (if?) v4.1 (or a later version) supports it, we need to refactor our codebase again.

At least having clarity in this matter would soften the hurt a bit, but this migration is starting to look really ugly. 😞

@zoechi
Copy link
Contributor

zoechi commented May 18, 2017

@bgever you can just create a custom Http class that extends Angulars Http class and customize there to your liking. There are several answers on SO how to do that.

@BorntraegerMarc
Copy link

@zoechi I think this discussion is more about how not to build a custom class but rather really only use Http interceptors. Which are two different use cases in my opinion.

@gund
Copy link

gund commented May 18, 2017

Whoever needs an elegant solution for interceptors, you can try ng-http-interceptors =)

@bgever
Copy link

bgever commented May 19, 2017

@BorntraegerMarc - spot on!
@gund - great library, thanks for the reference!

Considering not an exact port of AngularJS to Angular 4+, the custom Http classes do provide a more explicit way describe in the code that HTTP transformations are needed. E.g. we could have 2 custom classes for two different cross-origin APIs, having their own bearer tokens applied. It removes the "magic" of the interceptors. Also, I now understand the Observables allow a way to retry and cancel, neat! It's growing on me. 😄

@imgx64
Copy link

imgx64 commented Jun 5, 2017

According to this:

Similar stuff is planned for the new HttpClient

and

I don't think there is a public design doc yet

So I guess something is being cooked internally at Google and they won't tell us about it until it's ready. ¯\_(ツ)_/¯

@aitboudad
Copy link
Contributor

see #17143

@angular-automatic-lock-bot
Copy link

This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.

@angular-automatic-lock-bot angular-automatic-lock-bot bot locked and limited conversation to collaborators Sep 11, 2019
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests